├── testdata └── sample-1.mp4 ├── _examples ├── bubble-dl │ ├── demo.gif │ ├── demo.tape │ └── main.go ├── http-server │ ├── example-request-body.json │ ├── request-schema.json │ └── main.go ├── go.mod ├── simple │ └── main.go └── go.sum ├── .github ├── CODEOWNERS ├── workflows │ ├── generate-readme.yml │ ├── renovate.yml │ ├── test.yml │ ├── tag-semver.yml │ └── updater.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── bug_report.yml │ └── feature_request.yml ├── ytdlp-public.key ├── PULL_REQUEST_TEMPLATE.md ├── SUPPORT.md ├── SECURITY.md ├── CODE_OF_CONDUCT.md └── CONTRIBUTING.md ├── .vscode ├── extensions.json └── settings.json ├── .gitignore ├── command_others.go ├── debug.go ├── go.mod ├── cmd ├── codegen │ ├── go.mod │ ├── templates │ │ ├── builder_meta_args.gotmpl │ │ ├── builder_help.gotmpl │ │ ├── constants.gotmpl │ │ ├── buildertest.gotmpl │ │ ├── optiondata.gotmpl │ │ ├── builder.gotmpl │ │ └── command_json.gen.gotmpl │ ├── go.sum │ ├── option_data.go │ ├── main.go │ └── constants.go ├── gen-jsonschema │ ├── go.mod │ ├── go.sum │ └── main.go └── patch-ytdlp │ ├── run.sh │ └── export-options.patch ├── command_unix.go ├── command_windows.go ├── .editorconfig ├── LICENSE ├── constants_test.go ├── Makefile ├── go.sum ├── optiondata └── optiondata.go ├── results_test.go ├── errors.go ├── command_test.go ├── progress.go ├── install_ytdlp.go ├── .golangci.yaml ├── install_ffmpeg.go ├── command.go └── README.md /testdata/sample-1.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lrstanley/go-ytdlp/HEAD/testdata/sample-1.mp4 -------------------------------------------------------------------------------- /_examples/bubble-dl/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lrstanley/go-ytdlp/HEAD/_examples/bubble-dl/demo.gif -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # THIS FILE IS GENERATED! DO NOT EDIT! Maintained by Terraform. 2 | .github/* @lrstanley 3 | LICENSE @lrstanley 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "foxundermoon.shell-format", 4 | "golang.go", 5 | "jinliming2.vscode-go-template", 6 | "timonwong.shellcheck" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.github/workflows/generate-readme.yml: -------------------------------------------------------------------------------- 1 | name: generate-readme 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | tags: [v*] 7 | schedule: 8 | - cron: "0 13 * * *" 9 | 10 | jobs: 11 | generate: 12 | uses: lrstanley/.github/.github/workflows/generate-readme.yml@master 13 | secrets: inherit 14 | -------------------------------------------------------------------------------- /_examples/bubble-dl/demo.tape: -------------------------------------------------------------------------------- 1 | # go run github.com/charmbracelet/vhs@latest demo.tape 2 | Output demo.gif 3 | Require go 4 | 5 | Set Theme "Dracula+" 6 | Set FontSize 18 7 | Set Width 1600 8 | Set Height 450 9 | Set Margin 20 10 | Set MarginFill "#674EFF" 11 | Set BorderRadius 10 12 | Env PS1 "$ " 13 | 14 | Type "go run *.go" 15 | Sleep 500ms 16 | Enter 17 | Sleep 18s 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.test 2 | *.prof 3 | bin/* 4 | *.lock 5 | *.txt 6 | *.log 7 | .env 8 | *.mp4 9 | *.mpg 10 | !testdata/*.mp4 11 | cmd/patch-ytdlp/tmp/* 12 | cmd/patch-ytdlp/*.json 13 | _examples/bubble-dl/bubble-dl 14 | _examples/simple/simple 15 | .crush/ 16 | .cursor/ 17 | *.json 18 | !.vscode/*.json 19 | !json-schema.json 20 | !_examples/http-server/example-request-body.json 21 | !_examples/http-server/request-schema.json 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "**/*.gotmpl": "go-template", 4 | }, 5 | "files.exclude": { 6 | "**/*.example": true, 7 | "**/*.log": true, 8 | "**/*.sum": true, 9 | "**/.*ignore": true, 10 | "**/.golangci.yml": true, 11 | "**/LICENSE": true, 12 | "**/tmp": true, 13 | }, 14 | "go.lintTool": "golangci-lint", 15 | "gopls": { 16 | "formatting.gofumpt": true 17 | }, 18 | "shellformat.flag": "-s -i 0 -bn -ci -sr" 19 | } 20 | -------------------------------------------------------------------------------- /command_others.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | //go:build !windows && !unix 6 | 7 | package ytdlp 8 | 9 | import ( 10 | "os" 11 | "os/exec" 12 | ) 13 | 14 | // applySyscall applies any OS-specific syscall attributes to the command. 15 | func applySyscall(_ *exec.Cmd, _ bool) { 16 | // No-op by default. 17 | } 18 | 19 | func isExecutable(_ string, stat os.FileInfo) bool { 20 | return true // no-op. 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/renovate.yml: -------------------------------------------------------------------------------- 1 | name: renovate 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | force-run: 7 | description: >- 8 | Force a run regardless of the schedule configuration. 9 | required: false 10 | default: false 11 | type: boolean 12 | push: 13 | branches: [master] 14 | schedule: 15 | - cron: "0 5 1,15 * *" 16 | 17 | jobs: 18 | renovate: 19 | uses: lrstanley/.github/.github/workflows/renovate.yml@master 20 | secrets: inherit 21 | with: 22 | force-run: ${{ inputs.force-run == true || github.event_name == 'schedule' }} 23 | -------------------------------------------------------------------------------- /debug.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package ytdlp 6 | 7 | import ( 8 | "context" 9 | "log/slog" 10 | "os" 11 | "strconv" 12 | ) 13 | 14 | var debugLogger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) 15 | 16 | func debug(ctx context.Context, msg string, args ...any) { 17 | debug, _ := strconv.ParseBool(os.Getenv("YTDLP_DEBUG")) 18 | if !debug { 19 | return 20 | } 21 | debugLogger.DebugContext(ctx, msg, args...) 22 | } 23 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lrstanley/go-ytdlp 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.4 6 | 7 | require ( 8 | github.com/ProtonMail/go-crypto v1.3.0 9 | github.com/ulikunitz/xz v0.5.13 10 | ) 11 | 12 | require ( 13 | github.com/cloudflare/circl v1.6.1 // indirect 14 | golang.org/x/crypto v0.41.0 // indirect 15 | golang.org/x/sys v0.35.0 // indirect 16 | ) 17 | 18 | // Testing dependencies. Not pulled when "go get"ing. 19 | require ( 20 | github.com/davecgh/go-spew v1.1.1 // indirect 21 | github.com/pmezard/go-difflib v1.0.0 // indirect 22 | github.com/stretchr/testify v1.11.1 23 | gopkg.in/yaml.v3 v3.0.1 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /_examples/http-server/example-request-body.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./request-schema.json", 3 | "args": [ 4 | "https://www.youtube.com/watch?v=dQw4w9WgXcQ" 5 | ], 6 | "flags": { 7 | "verbosity_simulation": { 8 | "print_json": true, 9 | "no_progress": true 10 | }, 11 | "video_format": { 12 | "format_sort": "res,ext:mp4:m4a" 13 | }, 14 | "post_processing": { 15 | "recode_video": "mp4" 16 | }, 17 | "video_selection": { 18 | "no_playlist": true 19 | }, 20 | "filesystem": { 21 | "no_overwrites": true, 22 | "continue": true, 23 | "output": "%(extractor)s - %(title)s.%(ext)s" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /cmd/codegen/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lrstanley/go-ytdlp/cmd/codegen 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.4 6 | 7 | require ( 8 | github.com/Masterminds/sprig/v3 v3.3.0 9 | github.com/iancoleman/strcase v0.3.0 10 | ) 11 | 12 | require ( 13 | dario.cat/mergo v1.0.2 // indirect 14 | github.com/Masterminds/goutils v1.1.1 // indirect 15 | github.com/Masterminds/semver/v3 v3.4.0 // indirect 16 | github.com/google/uuid v1.6.0 // indirect 17 | github.com/huandu/xstrings v1.5.0 // indirect 18 | github.com/mitchellh/copystructure v1.2.0 // indirect 19 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 20 | github.com/shopspring/decimal v1.4.0 // indirect 21 | github.com/spf13/cast v1.9.2 // indirect 22 | golang.org/x/crypto v0.41.0 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /cmd/gen-jsonschema/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lrstanley/go-ytdlp/cmd/gen-jsonschema 2 | 3 | go 1.24.4 4 | 5 | replace github.com/lrstanley/go-ytdlp => ../.. 6 | 7 | require ( 8 | github.com/invopop/jsonschema v0.13.0 9 | github.com/lrstanley/go-ytdlp v1.2.2 10 | ) 11 | 12 | require ( 13 | github.com/ProtonMail/go-crypto v1.3.0 // indirect 14 | github.com/bahlo/generic-list-go v0.2.0 // indirect 15 | github.com/buger/jsonparser v1.1.1 // indirect 16 | github.com/cloudflare/circl v1.6.1 // indirect 17 | github.com/mailru/easyjson v0.9.0 // indirect 18 | github.com/ulikunitz/xz v0.5.13 // indirect 19 | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect 20 | golang.org/x/crypto v0.41.0 // indirect 21 | golang.org/x/sys v0.35.0 // indirect 22 | gopkg.in/yaml.v3 v3.0.1 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /command_unix.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | //go:build unix 6 | 7 | package ytdlp 8 | 9 | import ( 10 | "os" 11 | "os/exec" 12 | "syscall" 13 | ) 14 | 15 | // applySyscall applies any OS-specific syscall attributes to the command. 16 | func applySyscall(cmd *exec.Cmd, separateProcessGroup bool) { 17 | cmd.SysProcAttr = &syscall.SysProcAttr{ 18 | Setpgid: separateProcessGroup, 19 | } 20 | } 21 | 22 | func isExecutable(_ string, stat os.FileInfo) bool { 23 | // On Unix systems, check if executable bit is set (user, group, or others). 24 | return stat.Mode().Perm()&0o100 != 0 || stat.Mode().Perm()&0o010 != 0 || stat.Mode().Perm()&0o001 != 0 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | paths-ignore: [".gitignore", "**/*.md", ".github/ISSUE_TEMPLATE/**"] 7 | types: [opened, edited, reopened, synchronize, unlocked] 8 | push: 9 | branches: [master] 10 | paths-ignore: [".gitignore", "**/*.md", ".github/ISSUE_TEMPLATE/**"] 11 | 12 | jobs: 13 | go-test: 14 | uses: lrstanley/.github/.github/workflows/lang-go-test-matrix.yml@master 15 | permissions: 16 | contents: read 17 | with: { num-minor: 1, num-patch: 2 } 18 | go-lint: 19 | uses: lrstanley/.github/.github/workflows/lang-go-lint.yml@master 20 | permissions: 21 | checks: write 22 | contents: read 23 | pull-requests: read 24 | security-events: write 25 | statuses: write 26 | secrets: inherit 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # THIS FILE IS GENERATED! DO NOT EDIT! Maintained by Terraform. 2 | blank_issues_enabled: false 3 | contact_links: 4 | - name: "🙋‍♂️ Ask the community a question!" 5 | about: Have a question, that might not be a bug? Wondering how to solve a problem? Ask away! 6 | url: "https://github.com/lrstanley/go-ytdlp/discussions/new?category=q-a" 7 | - name: "🎉 Show us what you've made!" 8 | about: Have you built something using go-ytdlp, and want to show others? Post here! 9 | url: "https://github.com/lrstanley/go-ytdlp/discussions/new?category=show-and-tell" 10 | - name: "✋ Additional support information" 11 | about: Looking for something else? Check here. 12 | url: "https://github.com/lrstanley/go-ytdlp/blob/master/.github/SUPPORT.md" 13 | - name: "💬 Discord chat" 14 | about: On-topic and off-topic discussions. 15 | url: "https://liam.sh/chat" 16 | -------------------------------------------------------------------------------- /command_windows.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | //go:build windows 6 | 7 | package ytdlp 8 | 9 | import ( 10 | "debug/pe" 11 | "os" 12 | "os/exec" 13 | "syscall" 14 | ) 15 | 16 | // applySyscall applies any OS-specific syscall attributes to the command. 17 | func applySyscall(cmd *exec.Cmd, separateProcessGroup bool) { 18 | cmd.SysProcAttr = &syscall.SysProcAttr{ 19 | CreationFlags: 0x08000000, // CREATE_NO_WINDOW. 20 | HideWindow: true, 21 | } 22 | if separateProcessGroup { 23 | cmd.SysProcAttr.CreationFlags |= syscall.CREATE_NEW_PROCESS_GROUP 24 | } 25 | } 26 | 27 | func isExecutable(path string, _ os.FileInfo) bool { 28 | // Try to parse as PE (Portable Executable) format. 29 | f, err := pe.Open(path) 30 | if err != nil { 31 | return false 32 | } 33 | f.Close() 34 | return true 35 | } 36 | -------------------------------------------------------------------------------- /_examples/http-server/request-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft/2020-12/schema", 3 | "$id": "https://github.com/lrstanley/go-ytdlp/_examples/http-server", 4 | "$ref": "#/$defs/Config", 5 | "$defs": { 6 | "Config": { 7 | "type": "object", 8 | "additionalProperties": false, 9 | "properties": { 10 | "args": { 11 | "type": "array", 12 | "items": { 13 | "type": "string" 14 | }, 15 | "minItems": 1 16 | }, 17 | "flags": { 18 | "description": "replace this with a remote url to the json-schema.json file, or write the go-ytdlp schema somewhere which it can be referenced in your project.", 19 | "$ref": "../../optiondata/json-schema.json" 20 | }, 21 | "env": { 22 | "type": "object", 23 | "properties": { 24 | "type": "string" 25 | } 26 | } 27 | }, 28 | "required": [ 29 | "args" 30 | ] 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # THIS FILE IS GENERATED! DO NOT EDIT! Maintained by Terraform. 2 | # 3 | # editorconfig: https://editorconfig.org/ 4 | # actual source: https://github.com/lrstanley/.github/blob/master/terraform/github-common-files/templates/.editorconfig 5 | # 6 | 7 | root = true 8 | 9 | [*] 10 | charset = utf-8 11 | end_of_line = lf 12 | indent_size = 4 13 | indent_style = space 14 | insert_final_newline = true 15 | trim_trailing_whitespace = true 16 | max_line_length = 100 17 | 18 | [*.tf] 19 | indent_size = 2 20 | 21 | [*.go] 22 | indent_style = tab 23 | indent_size = 4 24 | 25 | [*.md] 26 | trim_trailing_whitespace = false 27 | 28 | [*.{md,py,sh,yml,yaml,cjs,js,ts,vue,css}] 29 | max_line_length = 105 30 | 31 | [*.{yml,yaml,toml}] 32 | indent_size = 2 33 | 34 | [*.json] 35 | indent_size = 2 36 | 37 | [*.html] 38 | max_line_length = 140 39 | indent_size = 2 40 | 41 | [*.{cjs,js,ts,vue,css}] 42 | indent_size = 2 43 | 44 | [Makefile] 45 | indent_style = tab 46 | 47 | [**.min.js] 48 | indent_style = ignore 49 | 50 | [*.bat] 51 | indent_style = tab 52 | -------------------------------------------------------------------------------- /.github/workflows/tag-semver.yml: -------------------------------------------------------------------------------- 1 | name: tag-semver 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | method: 7 | description: "Tagging method to use" 8 | required: true 9 | type: choice 10 | options: [major, minor, patch, alpha, rc, custom] 11 | custom: 12 | description: "Custom tag, if the default doesn't suffice. Must also use method 'custom'." 13 | required: false 14 | type: string 15 | ref: 16 | description: "Git ref to apply tag to (will use default branch if unspecified)." 17 | required: false 18 | type: string 19 | annotation: 20 | description: "Optional annotation to add to the commit." 21 | required: false 22 | type: string 23 | 24 | jobs: 25 | tag-semver: 26 | uses: lrstanley/.github/.github/workflows/tag-semver.yml@master 27 | secrets: inherit 28 | with: 29 | method: ${{ github.event.inputs.method }} 30 | ref: ${{ github.event.inputs.ref }} 31 | custom: ${{ github.event.inputs.custom }} 32 | annotation: ${{ github.event.inputs.annotation }} 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Liam Stanley 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 | -------------------------------------------------------------------------------- /cmd/codegen/templates/builder_meta_args.gotmpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | template: builder-meta-args 3 | */}} 4 | {{- define "builder-meta-args" -}} 5 | {{- $option := . -}} 6 | {{- if and (ne $option.Type "bool") (gt $option.NArgs 0) }} 7 | {{- $option.ArgNames | join ", " }} {{ if $option.Choices }}{{ $option.Name | to_camel }}Option{{ else }}{{ $option.Type }}{{ end }} 8 | {{- end }}{{/* end if type */}} 9 | {{- end -}}{{/* end define builder-meta-args */}} 10 | 11 | {{/* 12 | template: builder-test-args 13 | */}} 14 | {{- define "builder-test-args" -}} 15 | {{- $option := . -}} 16 | {{- range $n := until $option.NArgs -}} 17 | {{- if eq $option.Type "string" -}} 18 | {{- if $option.Choices -}} 19 | {{ $option.Choices | first | quote }} 20 | {{- else -}} 21 | "test" 22 | {{- end -}} 23 | {{- else if eq $option.Type "bool" -}} 24 | true 25 | {{- else if eq $option.Type "int" -}} 26 | 1 27 | {{- else if eq $option.Type "float64" -}} 28 | 1.0 29 | {{- end -}} 30 | {{- if not (last $n (until $option.NArgs)) }},{{ end -}} 31 | {{- end -}}{{/* end range for args */}} 32 | {{- end -}}{{/* end define builder-test-args */}} 33 | -------------------------------------------------------------------------------- /cmd/codegen/templates/builder_help.gotmpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | template: builder-help 3 | */}} 4 | {{- define "builder-help" -}} 5 | {{- $option := . -}} 6 | {{- if $option.Help }} 7 | // {{ wrap 80 $option.Help | replace "\n" "\n// " }} 8 | {{- else }} 9 | // {{ $option.Name | to_camel }} sets the {{ $option.Name | quote }} flag (no description specified). 10 | {{- end }}{{/* end if help */}} 11 | {{- if $option.URLs }} 12 | // 13 | // References: 14 | {{- range $url := $option.URLs }} 15 | // - {{ $url.Name }}: {{ $url.URL }} 16 | {{- end }}{{/* end range urls */}} 17 | {{- end }}{{/* end if urls */}} 18 | // 19 | // Additional information: 20 | {{- if not $option.Executable }} 21 | // - See [Command.Unset{{ $option.Name | to_camel | trimPrefix "No" | trimPrefix "Yes" }}], for unsetting the flag. 22 | {{- end }}{{/* end if executable */}} 23 | // - {{ $option.Name | to_camel }} maps to cli flags: {{ $option.AllFlags | join "/" }}{{ if $option.MetaArgs }}={{ $option.MetaArgs }}{{ end }}{{ if $option.Hidden }} (hidden){{ end }}. 24 | // - From option group: {{ $option.Parent.Name | quote }} 25 | {{- if $option.Deprecated }} 26 | // 27 | // Deprecated: {{ $option.Deprecated }} 28 | {{- end }}{{/* end if deprecated */}} 29 | {{- end -}}{{/* end define builder-help */}} 30 | -------------------------------------------------------------------------------- /constants_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | //nolint:forbidigo 6 | package ytdlp 7 | 8 | import ( 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func TestConstant_ValidateMain(t *testing.T) { 14 | if Channel == "" { 15 | t.Fatal("Channel is empty") 16 | } 17 | 18 | if Version == "" { 19 | t.Fatal("Version is empty") 20 | } 21 | 22 | _, err := time.Parse("2006.01.02", Version) 23 | if err != nil { 24 | t.Fatalf("failed to parse version: %v", err) 25 | } 26 | } 27 | 28 | func TestConstant_ValidateExtractors(t *testing.T) { 29 | if len(SupportedExtractors) == 0 { 30 | t.Fatal("SupportedExtractors is empty") 31 | } 32 | 33 | withDescriptions := 0 34 | withAgeLimit := 0 35 | for _, e := range SupportedExtractors { 36 | if e.Name == "" { 37 | t.Fatal("extractor has no name") 38 | } 39 | 40 | if e.Description != "" { 41 | withDescriptions++ 42 | } 43 | 44 | if e.AgeLimit != 0 { 45 | withAgeLimit++ 46 | } 47 | } 48 | 49 | if withDescriptions == 0 { 50 | t.Fatal("no extractors have descriptions") 51 | } 52 | 53 | if withAgeLimit == 0 { 54 | t.Fatal("no extractors have age limits") 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /cmd/patch-ytdlp/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | #shellcheck disable=SC2155 3 | 4 | export BASE="$(dirname "$(readlink -f "$0")")" 5 | 6 | YTDLP_VERSION=${1?:"usage: $0 "} 7 | PATCH_DIR="${BASE}/tmp/${YTDLP_VERSION}" 8 | EXPORT_FILE="${BASE}/export-${YTDLP_VERSION}.json" 9 | 10 | # if [ -d "$PATCH_DIR" ]; then 11 | # echo "yt-dlp patch already completed for version, not doing anything" 12 | # exit 0 13 | # fi 14 | 15 | if [ -f "$EXPORT_FILE" ]; then 16 | echo "yt-dlp export already exists for version at '${EXPORT_FILE}', not doing anything" 17 | exit 0 18 | fi 19 | 20 | echo "patching yt-dlp @ ${YTDLP_VERSION}" 21 | 22 | if [ -d "$PATCH_DIR" ]; then 23 | rm -rf "$PATCH_DIR" 24 | fi 25 | 26 | mkdir -vp "$PATCH_DIR" 27 | 28 | ( 29 | set -x 30 | git \ 31 | -c advice.detachedHead=false \ 32 | clone \ 33 | --depth 1 \ 34 | --branch "$YTDLP_VERSION" \ 35 | https://github.com/yt-dlp/yt-dlp.git "$PATCH_DIR" 36 | ) 37 | 38 | cd "$PATCH_DIR" 39 | 40 | if ! grep -q -- "--export-options" "yt_dlp/__main__.py"; then 41 | ( 42 | set -x 43 | git apply "${BASE}/export-options.patch" 44 | ) 45 | fi 46 | 47 | if which uv > /dev/null; then 48 | uv -q run --python 3.10 --no-project python -m yt_dlp --export-options > "$EXPORT_FILE" 49 | else 50 | python3 -m yt_dlp --export-options > "$EXPORT_FILE" 51 | fi 52 | -------------------------------------------------------------------------------- /cmd/codegen/templates/constants.gotmpl: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | // 5 | // Code generated by cmd/codegen. DO NOT EDIT. 6 | 7 | package ytdlp 8 | 9 | const ( 10 | // Channel of yt-dlp that go-ytdlp was generated with. 11 | Channel = {{ .Channel | quote }} 12 | 13 | // Version of yt-dlp that go-ytdlp was generated with. 14 | Version = {{ .Version | quote }} 15 | ) 16 | 17 | // Extractor contains information about a specific yt-dlp extractor. Extractors are 18 | // used to extract information from a specific URL, and subsequently download the 19 | // video/audio. 20 | type Extractor struct { 21 | // Name of the extractor. 22 | Name string `json:"name"` 23 | 24 | // Description of the extractor. 25 | Description string `json:"description,omitempty"` 26 | 27 | // AgeLimit of the extractor. 28 | AgeLimit int `json:"age_limit,omitempty"` 29 | } 30 | 31 | var SupportedExtractors = []*Extractor{ 32 | {{- range .Extractors }} 33 | { 34 | {{- "" -}} 35 | Name: {{ .Name | quote }}, 36 | {{- if and (.Description) (ne .Description .Name) }}Description: {{ .Description | trimPrefix (printf "%s: " .Name) | trim | quote }},{{- end }} 37 | {{- if .AgeLimit }}AgeLimit: {{ .AgeLimit }},{{- end }} 38 | {{- "" -}} 39 | }, 40 | {{- end }} 41 | } 42 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := generate 2 | 3 | export YTDLP_VERSION := 2025.10.22 4 | 5 | license: 6 | curl -sL https://liam.sh/-/gh/g/license-header.sh | bash -s 7 | 8 | clean: 9 | rm -rf ./cmd/patch-ytdlp/tmp/${YTDLP_VERSION} ./cmd/patch-ytdlp/export-${YTDLP_VERSION}.json 10 | 11 | fetch: 12 | cd ./cmd/codegen && go mod tidy 13 | cd ./cmd/gen-jsonschema && go mod tidy 14 | go mod tidy 15 | 16 | up: 17 | cd ./cmd/codegen && go get -u -t ./... && go mod tidy 18 | cd ./cmd/gen-jsonschema && go get -u -t ./... && go mod tidy 19 | cd ./_examples && go get -u -t ./... && go mod tidy 20 | go get -u -t ./... && go mod tidy 21 | 22 | commit: generate 23 | git add --all \ 24 | Makefile \ 25 | *.gen.go *.gen_test.go \ 26 | optiondata/*.gen.go 27 | git commit -m "chore(codegen): generate updated cli bindings" 28 | 29 | edit-patch: clean patch 30 | cd ./cmd/patch-ytdlp/tmp/${YTDLP_VERSION} && ${EDITOR} yt_dlp/options.py && git diff > ../../export-options.patch 31 | 32 | patch: 33 | @# git diff --minimal -U1 > ../../export-options.patch 34 | ./cmd/patch-ytdlp/run.sh ${YTDLP_VERSION} 35 | 36 | test: fetch 37 | GORACE='exitcode=1 halt_on_error=1' go test -v -race -timeout 3m -count 3 ./... 38 | 39 | generate: license fetch patch 40 | rm -rf \ 41 | *.gen.go *.gen_test.go \ 42 | optiondata/*.gen.go 43 | cd ./cmd/codegen && go run . ../patch-ytdlp/export-${YTDLP_VERSION}.json ../../ 44 | gofmt -e -s -w . 45 | cd ./cmd/gen-jsonschema && go run . ../../optiondata/ 46 | go vet . 47 | go test -v ./... 48 | -------------------------------------------------------------------------------- /.github/workflows/updater.yml: -------------------------------------------------------------------------------- 1 | name: updater 2 | 3 | on: 4 | schedule: 5 | - cron: "0 */6 * * *" # every 6 hours 6 | workflow_dispatch: {} 7 | 8 | jobs: 9 | yt-dlp: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: lrstanley/.github/composite/go-versions@master 14 | id: goversion 15 | - name: install-go 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version: "${{ steps.goversion.outputs.version }}" 19 | cache: false 20 | - id: release 21 | uses: lrstanley/.github/composite/get-release-version@master 22 | with: 23 | token: ${{ secrets.GITHUB_TOKEN }} 24 | repo: yt-dlp/yt-dlp 25 | - id: version 26 | run: | 27 | echo "previous_version=$(sed -rn 's/export YTDLP_VERSION := (.+)/\1/p' Makefile)" >> "$GITHUB_OUTPUT" 28 | sed -ri 's/YTDLP_VERSION := .+/YTDLP_VERSION := '${{ steps.release.outputs.version }}'/g' Makefile 29 | 30 | make generate 31 | - uses: lrstanley/.github/composite/pr-version-updater@master 32 | with: 33 | token: ${{ secrets.USER_PAT }} 34 | tool: yt-dlp 35 | chore: deps 36 | version: ${{ steps.release.outputs.version }} 37 | previous_version: ${{ steps.version.outputs.previous_version }} 38 | paths: | 39 | Makefile 40 | *.gen.go 41 | *.gen_test.go 42 | optiondata/*.gen.go 43 | repo: ${{ steps.release.outputs.repo }} 44 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= 2 | github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 3 | github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= 4 | github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 10 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 11 | github.com/ulikunitz/xz v0.5.13 h1:ar98gWrjf4H1ev05fYP/o29PDZw9DrI3niHtnEqyuXA= 12 | github.com/ulikunitz/xz v0.5.13/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 13 | golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= 14 | golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 15 | golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 16 | golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 17 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 18 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 19 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 20 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 21 | -------------------------------------------------------------------------------- /.github/ytdlp-public.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | mQINBGP78C4BEAD0rF9zjGPAt0thlt5C1ebzccAVX7Nb1v+eqQjk+WEZdTETVCg3 4 | WAM5ngArlHdm/fZqzUgO+pAYrB60GKeg7ffUDf+S0XFKEZdeRLYeAaqqKhSibVal 5 | DjvOBOztu3W607HLETQAqA7wTPuIt2WqmpL60NIcyr27LxqmgdN3mNvZ2iLO+bP0 6 | nKR/C+PgE9H4ytywDa12zMx6PmZCnVOOOu6XZEFmdUxxdQ9fFDqd9LcBKY2LDOcS 7 | Yo1saY0YWiZWHtzVoZu1kOzjnS5Fjq/yBHJLImDH7pNxHm7s/PnaurpmQFtDFruk 8 | t+2lhDnpKUmGr/I/3IHqH/X+9nPoS4uiqQ5HpblB8BK+4WfpaiEg75LnvuOPfZIP 9 | KYyXa/0A7QojMwgOrD88ozT+VCkKkkJ+ijXZ7gHNjmcBaUdKK7fDIEOYI63Lyc6Q 10 | WkGQTigFffSUXWHDCO9aXNhP3ejqFWgGMtCUsrbkcJkWuWY7q5ARy/05HbSM3K4D 11 | U9eqtnxmiV1WQ8nXuI9JgJQRvh5PTkny5LtxqzcmqvWO9TjHBbrs14BPEO9fcXxK 12 | L/CFBbzXDSvvAgArdqqlMoncQ/yicTlfL6qzJ8EKFiqW14QMTdAn6SuuZTodXCTi 13 | InwoT7WjjuFPKKdvfH1GP4bnqdzTnzLxCSDIEtfyfPsIX+9GI7Jkk/zZjQARAQAB 14 | tDdTaW1vbiBTYXdpY2tpICh5dC1kbHAgc2lnbmluZyBrZXkpIDxjb250YWN0QGdy 15 | dWI0ay54eXo+iQJOBBMBCgA4FiEErAy75oSNaoc0ZK9OV89lkztadYEFAmP78C4C 16 | GwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQV89lkztadYEVqQ//cW7TxhXg 17 | 7Xbh2EZQzXml0egn6j8QaV9KzGragMiShrlvTO2zXfLXqyizrFP4AspgjSn/4NrI 18 | 8mluom+Yi+qr7DXT4BjQqIM9y3AjwZPdywe912Lxcw52NNoPZCm24I9T7ySc8lmR 19 | FQvZC0w4H/VTNj/2lgJ1dwMflpwvNRiWa5YzcFGlCUeDIPskLx9++AJE+xwU3LYm 20 | jQQsPBqpHHiTBEJzMLl+rfd9Fg4N+QNzpFkTDW3EPerLuvJniSBBwZthqxeAtw4M 21 | UiAXh6JvCc2hJkKCoygRfM281MeolvmsGNyQm+axlB0vyldiPP6BnaRgZlx+l6MU 22 | cPqgHblb7RW5j9lfr6OYL7SceBIHNv0CFrt1OnkGo/tVMwcs8LH3Ae4a7UJlIceL 23 | V54aRxSsZU7w4iX+PB79BWkEsQzwKrUuJVOeL4UDwWajp75OFaUqbS/slDDVXvK5 24 | OIeuth3mA/adjdvgjPxhRQjA3l69rRWIJDrqBSHldmRsnX6cvXTDy8wSXZgy51lP 25 | m4IVLHnCy9m4SaGGoAsfTZS0cC9FgjUIyTyrq9M67wOMpUxnuB0aRZgJE1DsI23E 26 | qdvcSNVlO+39xM/KPWUEh6b83wMn88QeW+DCVGWACQq5N3YdPnAJa50617fGbY6I 27 | gXIoRHXkDqe23PZ/jURYCv0sjVtjPoVC+bg= 28 | =bJkn 29 | -----END PGP PUBLIC KEY BLOCK----- 30 | -------------------------------------------------------------------------------- /_examples/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lrstanley/go-ytdlp/_examples 2 | 3 | go 1.23.0 4 | 5 | replace github.com/lrstanley/go-ytdlp => ../ 6 | 7 | require ( 8 | github.com/charmbracelet/bubbles v0.21.0 9 | github.com/charmbracelet/bubbletea v1.3.6 10 | github.com/charmbracelet/lipgloss v1.1.0 11 | github.com/dustin/go-humanize v1.0.1 12 | github.com/lrstanley/go-ytdlp v1.2.2 13 | github.com/samber/slog-http v1.7.0 14 | ) 15 | 16 | require ( 17 | github.com/ProtonMail/go-crypto v1.3.0 // indirect 18 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 19 | github.com/charmbracelet/colorprofile v0.3.2 // indirect 20 | github.com/charmbracelet/harmonica v0.2.0 // indirect 21 | github.com/charmbracelet/x/ansi v0.10.1 // indirect 22 | github.com/charmbracelet/x/cellbuf v0.0.13 // indirect 23 | github.com/charmbracelet/x/term v0.2.1 // indirect 24 | github.com/cloudflare/circl v1.6.1 // indirect 25 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 26 | github.com/google/uuid v1.6.0 // indirect 27 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 28 | github.com/mattn/go-isatty v0.0.20 // indirect 29 | github.com/mattn/go-localereader v0.0.1 // indirect 30 | github.com/mattn/go-runewidth v0.0.16 // indirect 31 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 32 | github.com/muesli/cancelreader v0.2.2 // indirect 33 | github.com/muesli/termenv v0.16.0 // indirect 34 | github.com/rivo/uniseg v0.4.7 // indirect 35 | github.com/ulikunitz/xz v0.5.13 // indirect 36 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 37 | go.opentelemetry.io/otel v1.37.0 // indirect 38 | go.opentelemetry.io/otel/trace v1.37.0 // indirect 39 | golang.org/x/crypto v0.41.0 // indirect 40 | golang.org/x/sync v0.16.0 // indirect 41 | golang.org/x/sys v0.35.0 // indirect 42 | golang.org/x/text v0.28.0 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /_examples/simple/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "encoding/json" 10 | "fmt" 11 | "log/slog" 12 | "os" 13 | "time" 14 | 15 | "github.com/lrstanley/go-ytdlp" 16 | ) 17 | 18 | func main() { 19 | // Use the following env var if you want to debug the download process for yt-dlp, ffmpeg, and ffprobe, 20 | // as well as print out associated yt-dlp commands. 21 | // os.Setenv("YTDLP_DEBUG", "true") 22 | 23 | // If yt-dlp/ffmpeg/ffprobe isn't installed yet, download and cache the binaries for further use. 24 | // Note that the download/installation of ffmpeg/ffprobe is only supported on a handful of platforms, 25 | // and so it is still recommended to install ffmpeg/ffprobe via other means. 26 | ytdlp.MustInstallAll(context.TODO()) 27 | 28 | dl := ytdlp.New(). 29 | PrintJSON(). 30 | NoProgress(). 31 | FormatSort("res,ext:mp4:m4a"). 32 | RecodeVideo("mp4"). 33 | NoPlaylist(). 34 | NoOverwrites(). 35 | Continue(). 36 | ProgressFunc(100*time.Millisecond, func(prog ytdlp.ProgressUpdate) { 37 | fmt.Printf( //nolint:forbidigo 38 | "%s @ %s [eta: %s] :: %s\n", 39 | prog.Status, 40 | prog.PercentString(), 41 | prog.ETA(), 42 | prog.Filename, 43 | ) 44 | }). 45 | Output("%(extractor)s - %(title)s.%(ext)s") 46 | 47 | r, err := dl.Run(context.TODO(), "https://www.youtube.com/watch?v=dQw4w9WgXcQ") 48 | if err != nil { 49 | panic(err) 50 | } 51 | 52 | f, err := os.Create("results.json") 53 | if err != nil { 54 | panic(err) 55 | } 56 | defer f.Close() 57 | 58 | enc := json.NewEncoder(f) 59 | enc.SetIndent("", " ") 60 | 61 | if err = enc.Encode(r); err != nil { 62 | panic(err) 63 | } 64 | 65 | slog.Info("wrote results to results.json") 66 | } 67 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | ## 🚀 Changes proposed by this PR 9 | 10 | 14 | 15 | 16 | ### 🔗 Related bug reports/feature requests 17 | 18 | 22 | - fixes #(issue) 23 | - closes #(issue) 24 | - relates to #(issue) 25 | - implements #(feature) 26 | 27 | ### 🧰 Type of change 28 | 29 | 30 | - [ ] Bug fix (non-breaking change which fixes an issue). 31 | - [ ] New feature (non-breaking change which adds functionality). 32 | - [ ] Breaking change (fix or feature that causes existing functionality to not work as expected). 33 | - [ ] This change requires (or is) a documentation update. 34 | 35 | ### 📝 Notes to reviewer 36 | 37 | 41 | 42 | ### 🤝 Requirements 43 | 44 | - [ ] ✍ I have read and agree to this projects [Code of Conduct](../../blob/master/.github/CODE_OF_CONDUCT.md). 45 | - [ ] ✍ I have read and agree to this projects [Contribution Guidelines](../../blob/master/.github/CONTRIBUTING.md). 46 | - [ ] ✍ I have read and agree to the [Developer Certificate of Origin](https://developercertificate.org/). 47 | - [ ] 🔎 I have performed a self-review of my own changes. 48 | - [ ] 🎨 My changes follow the style guidelines of this project. 49 | 50 | - [ ] 💬 My changes as properly commented, primarily for hard-to-understand areas. 51 | - [ ] 📝 I have made corresponding changes to the documentation. 52 | - [ ] 🧪 I have included tests (if necessary) for this change. 53 | -------------------------------------------------------------------------------- /.github/SUPPORT.md: -------------------------------------------------------------------------------- 1 | # :raising_hand_man: Support 2 | 3 | This document explains where and how to get help with most of my projects. 4 | Please ensure you read through it thoroughly. 5 | 6 | > :point_right: **Note**: before participating in the community, please read our 7 | > [Code of Conduct][coc]. 8 | > By interacting with this repository, organization, or community you agree to 9 | > abide by its terms. 10 | 11 | ## :grey_question: Asking quality questions 12 | 13 | Questions can go to [Github Discussions][discussions] or feel free to join 14 | the Discord [here][chat]. 15 | 16 | Help me help you! Spend time framing questions and add links and resources. 17 | Spending the extra time up front can help save everyone time in the long run. 18 | Here are some tips: 19 | 20 | * Don't fall for the [XY problem][xy]. 21 | * Search to find out if a similar question has been asked or if a similar 22 | issue/bug has been reported. 23 | * Try to define what you need help with: 24 | * Is there something in particular you want to do? 25 | * What problem are you encountering and what steps have you taken to try 26 | and fix it? 27 | * Is there a concept you don't understand? 28 | * Provide sample code, such as a [CodeSandbox][cs] or a simple snippet, if 29 | possible. 30 | * Screenshots can help, but if there's important text such as code or error 31 | messages in them, please also provide those. 32 | * The more time you put into asking your question, the better I and others 33 | can help you. 34 | 35 | ## :old_key: Security 36 | 37 | For any security or vulnerability related disclosure, please follow the 38 | guidelines outlined in our [security policy][security]. 39 | 40 | ## :handshake: Contributions 41 | 42 | See [`CONTRIBUTING.md`][contributing] on how to contribute. 43 | 44 | 45 | [coc]: https://github.com/lrstanley/go-ytdlp/blob/master/.github/CODE_OF_CONDUCT.md 46 | [contributing]: https://github.com/lrstanley/go-ytdlp/blob/master/.github/CONTRIBUTING.md 47 | [discussions]: https://github.com/lrstanley/go-ytdlp/discussions/categories/q-a 48 | [issues]: https://github.com/lrstanley/go-ytdlp/issues/new/choose 49 | [license]: https://github.com/lrstanley/go-ytdlp/blob/master/LICENSE 50 | [pull-requests]: https://github.com/lrstanley/go-ytdlp/issues/new/choose 51 | [security]: https://github.com/lrstanley/go-ytdlp/security/policy 52 | [support]: https://github.com/lrstanley/go-ytdlp/blob/master/.github/SUPPORT.md 53 | 54 | [xy]: https://meta.stackexchange.com/questions/66377/what-is-the-xy-problem/66378#66378 55 | [chat]: https://liam.sh/chat 56 | [cs]: https://codesandbox.io 57 | -------------------------------------------------------------------------------- /cmd/gen-jsonschema/go.sum: -------------------------------------------------------------------------------- 1 | github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= 2 | github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 3 | github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= 4 | github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= 5 | github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= 6 | github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 7 | github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= 8 | github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= 12 | github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= 13 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= 14 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 15 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 16 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 17 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 18 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 19 | github.com/ulikunitz/xz v0.5.13 h1:ar98gWrjf4H1ev05fYP/o29PDZw9DrI3niHtnEqyuXA= 20 | github.com/ulikunitz/xz v0.5.13/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 21 | github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= 22 | github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= 23 | golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= 24 | golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 25 | golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 26 | golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 27 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 28 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 29 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 30 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 31 | -------------------------------------------------------------------------------- /cmd/codegen/templates/buildertest.gotmpl: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | // 5 | // Code generated by cmd/codegen. DO NOT EDIT. 6 | 7 | package ytdlp 8 | 9 | import ( 10 | "testing" 11 | ) 12 | 13 | func validateFlagAdded(t *testing.T, builder *Command, dest, flag string, nargs, expectedCount int) { 14 | t.Helper() 15 | 16 | err := builder.flagConfig.Validate() 17 | if err != nil { 18 | t.Fatalf("expected no validation errors, but got: %v", err) 19 | } 20 | 21 | flags := builder.flagConfig.ToFlags().FindByID(dest) 22 | 23 | if len(flags) != expectedCount { 24 | t.Errorf("expected flag %q (dest: %q) to be added %d times, but it was added %d times", flag, dest, expectedCount, len(flags)) 25 | } 26 | 27 | for _, f := range flags { 28 | if f.Flag != flag || len(f.Args) != nargs { 29 | t.Errorf("expected flag %q (dest: %q) to be added, but it was not (or was incorrectly", flag, dest) 30 | } 31 | 32 | // Make sure flag.Raw() doesn't panic and has at least some content. 33 | raw := f.Raw() 34 | if raw == nil { 35 | t.Errorf("expected flag %q (dest: %q) to have a non-nil Raw() value", flag, dest) 36 | } 37 | 38 | if len(raw) != nargs + 1 { 39 | t.Errorf("expected flag %q.Raw() (dest: %q) to have %d args, but it had %d", flag, dest, nargs, len(raw) - 1) 40 | } 41 | } 42 | 43 | } 44 | 45 | func validateFlagRemoved(t *testing.T, builder *Command, dest, flag string) { 46 | t.Helper() 47 | 48 | err := builder.flagConfig.Validate() 49 | if err != nil { 50 | t.Fatalf("expected no validation errors, but got: %v", err) 51 | } 52 | 53 | if len(builder.flagConfig.ToFlags().FindByID(dest)) != 0 { 54 | t.Errorf("expected flag %q (dest: %q) to be removed, but it was not", flag, dest) 55 | } 56 | } 57 | 58 | {{ range $group := .OptionGroups }} 59 | func TestBuilder_{{ $group.Name | to_camel | trimSuffix "s" }}_NonExecutable(t *testing.T) { 60 | t.Parallel() 61 | {{- range $i, $option := .Options }} 62 | {{- $id := ($option.Name | to_camel) -}} 63 | {{- if $option.Executable }}{{ continue }}{{ end }} 64 | t.Run({{ $id | quote }}, func(t *testing.T) { 65 | t.Parallel() 66 | 67 | builder := New().NoUpdate(). 68 | {{ $id }}({{ template "builder-test-args" $option }}). 69 | {{ $id }}({{ template "builder-test-args" $option }}) 70 | validateFlagAdded(t, builder, {{ $option.ID | quote }}, {{ $option.Flag | quote }}, {{ $option.NArgs }}, {{ if $option.AllowsMultiple }}2{{ else }}1{{ end }}) 71 | _ = builder.Unset{{ $id | trimPrefix "No" | trimPrefix "Yes" }}() 72 | validateFlagRemoved(t, builder, {{ $option.ID | quote }}, {{ $option.Flag | quote }}) 73 | }) 74 | {{- end }}{{/* end range for options */}} 75 | } 76 | {{ end }}{{/* end range for option groups */}} 77 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | # :old_key: Security Policy 3 | 4 | ## :heavy_check_mark: Supported Versions 5 | 6 | The following restrictions apply for versions that are still supported in terms of security and bug fixes: 7 | 8 | * :grey_question: Must be using the latest major/minor version. 9 | * :grey_question: Must be using a supported platform for the repository (e.g. OS, browser, etc), and that platform must 10 | be within its supported versions (for example: don't use a legacy or unsupported version of Ubuntu or 11 | Google Chrome). 12 | * :grey_question: Repository must not be archived (unless the vulnerability is critical, and the repository moderately 13 | popular). 14 | * :heavy_check_mark: 15 | 16 | If one of the above doesn't apply to you, feel free to submit an issue and we can discuss the 17 | issue/vulnerability further. 18 | 19 | 20 | ## :lady_beetle: Reporting a Vulnerability 21 | 22 | Best method of contact: [GPG :key:](https://github.com/lrstanley.gpg) 23 | 24 | * :speech_balloon: [Discord][chat]: message `lrstanley` (`/home/liam#0000`). 25 | * :email: Email: `security@liam.sh` 26 | 27 | Backup contacts (if I am unresponsive after **48h**): [GPG :key:](https://github.com/FM1337.gpg) 28 | * :speech_balloon: [Discord][chat]: message `Allen#7440`. 29 | * :email: Email: `security@allenlydiard.ca` 30 | 31 | If you feel that this disclosure doesn't include a critical vulnerability and there is no sensitive 32 | information in the disclosure, you don't have to use the GPG key. For all other situations, please 33 | use it. 34 | 35 | ### :stopwatch: Vulnerability disclosure expectations 36 | 37 | * :no_bell: We expect you to not share this information with others, unless: 38 | * The maximum timeline for initial response has been exceeded (shown below). 39 | * The maximum resolution time has been exceeded (shown below). 40 | * :mag_right: We expect you to responsibly investigate this vulnerability -- please do not utilize the 41 | vulnerability beyond the initial findings. 42 | * :stopwatch: Initial response within 48h, however, if the primary contact shown above is unavailable, please 43 | use the backup contacts provided. The maximum timeline for an initial response should be within 44 | 7 days. 45 | * :stopwatch: Depending on the severity of the disclosure, resolution time may be anywhere from 24h to 2 46 | weeks after initial response, though in most cases it will likely be closer to the former. 47 | * If the vulnerability is very low/low in terms of risk, the above timelines **will not apply**. 48 | * :toolbox: Before the release of resolved versions, a [GitHub Security Advisory][advisory-docs]. 49 | will be released on the respective repository. [Browser all advisories here][advisory]. 50 | 51 | 52 | [chat]: https://liam.sh/chat 53 | [advisory]: https://github.com/advisories?query=type%3Areviewed+ecosystem%3Ago 54 | [advisory-docs]: https://docs.github.com/en/code-security/repository-security-advisories/creating-a-repository-security-advisory 55 | -------------------------------------------------------------------------------- /cmd/gen-jsonschema/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "encoding/json" 9 | "log/slog" 10 | "os" 11 | "path/filepath" 12 | "slices" 13 | "sort" 14 | 15 | "github.com/invopop/jsonschema" 16 | "github.com/lrstanley/go-ytdlp" 17 | ) 18 | 19 | type UIDMapper struct { 20 | UID string 21 | Props []string 22 | } 23 | 24 | func main() { 25 | slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ 26 | Level: slog.LevelDebug, 27 | }))) 28 | 29 | ref := jsonschema.Reflector{ 30 | AllowAdditionalProperties: false, 31 | } 32 | 33 | s := ref.Reflect(&ytdlp.FlagConfig{}) 34 | 35 | for name, def := range s.Definitions { 36 | if def.Type != "object" { 37 | slog.Debug("skipping non-object definition", "name", name) 38 | continue 39 | } 40 | uid2prop := make(map[string][]string) 41 | 42 | for propPair := def.Properties.Oldest(); propPair != nil; propPair = propPair.Next() { 43 | uid, ok := propPair.Value.Extras["uid"] 44 | if !ok { 45 | continue 46 | } 47 | uid2prop[uid.(string)] = append(uid2prop[uid.(string)], propPair.Key) 48 | } 49 | slog.Debug("uid2prop", "uid2prop", uid2prop) 50 | 51 | // Convert uid2prop to an array, so we can sort and get a deterministic order, thus 52 | // preventing the jsonschema output from being different each time. 53 | uidPropMap := make([]UIDMapper, 0, len(uid2prop)) 54 | for uid, props := range uid2prop { 55 | slices.Sort(props) 56 | uidPropMap = append(uidPropMap, UIDMapper{ 57 | UID: uid, 58 | Props: props, 59 | }) 60 | } 61 | 62 | sort.Slice(uidPropMap, func(i, j int) bool { 63 | return uidPropMap[i].UID < uidPropMap[j].UID 64 | }) 65 | 66 | for _, propMap := range uidPropMap { 67 | if len(propMap.Props) < 2 { 68 | continue 69 | } 70 | 71 | slog.Debug("adding all-of condition due to duplicate uids", "name", name, "props", propMap.Props) 72 | 73 | for _, prop := range propMap.Props { 74 | slog.Debug("processing all-of for duplicate prop", "name", name, "prop", prop) 75 | 76 | var otherProps []string 77 | 78 | // Remove the current prop from the list of other props. 79 | for _, p := range propMap.Props { 80 | if p == prop { 81 | continue 82 | } 83 | otherProps = append(otherProps, p) 84 | } 85 | 86 | cond := &jsonschema.Schema{ 87 | If: &jsonschema.Schema{Required: []string{prop}}, 88 | Then: &jsonschema.Schema{ 89 | Not: &jsonschema.Schema{Required: otherProps}, 90 | }, 91 | } 92 | 93 | def.AllOf = append(def.AllOf, cond) 94 | } 95 | } 96 | } 97 | 98 | f, err := os.OpenFile(filepath.Join(os.Args[1], "json-schema.json"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) 99 | if err != nil { 100 | slog.Error("failed to open file", "error", err) 101 | os.Exit(1) 102 | } 103 | defer f.Close() 104 | 105 | enc := json.NewEncoder(f) 106 | enc.SetIndent("", " ") 107 | err = enc.Encode(s) 108 | if err != nil { 109 | slog.Error("failed to marshal JSON", "error", err) 110 | os.Exit(1) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /optiondata/optiondata.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | // Package optiondata contains the raw option data for go-ytdlp. Contents of this 6 | // package are generated via cmd/codegen, and may change at any time. 7 | package optiondata 8 | 9 | import _ "embed" 10 | 11 | //go:embed json-schema.json 12 | var JSONSchema []byte 13 | 14 | // OptionGroup is a group of options (e.g. general, verbosity, etc). 15 | type OptionGroup struct { 16 | // Name of the option group. 17 | Name string `json:"name"` 18 | // Description of the option group, if any. 19 | Description string `json:"description,omitempty"` 20 | // Options are the options within the group. 21 | Options []*Option `json:"options"` 22 | } 23 | 24 | // Option is the raw option data for the given option (flag, essentially). 25 | type Option struct { 26 | // ID is the identifier for the option, if one exists (may not for executables). 27 | // Note that this ID is not unique, as multiple options can have the same ID 28 | // (e.g. --something and --no-something). 29 | ID string `json:"id,omitempty"` 30 | // Name is the simplified name, based off the first found flags. 31 | Name string `json:"name"` 32 | // NameCamelCase is the same as [Option.Name], but in camelCase. 33 | NameCamelCase string `json:"name_camel_case"` 34 | // NamePascalCase is the same as [Option.Name], but in PascalCase. 35 | NamePascalCase string `json:"name_pascal_case"` 36 | // NameSnakeCase is the same as [Option.Name], but in snake_case. 37 | NameSnakeCase string `json:"name_snake_case"` 38 | // Links are optional links to the documentation for the option. 39 | URLs []*OptionURL `json:"urls,omitempty"` 40 | // DefaultFlag is the first flag (priority on long flags). 41 | DefaultFlag string `json:"default_flag"` 42 | // ArgNames are the argument names, if any -- length should match [Option.NArgs]. 43 | ArgNames []string `json:"arg_names,omitempty"` 44 | // Executable is true if the option doesn't accept arguments. 45 | Executable bool `json:"executable"` 46 | // Deprecated will contain the deprecation description if the option if deprecated. 47 | Deprecated string `json:"deprecated,omitempty"` 48 | // Choices contains the list of required inputs for the option, if the option 49 | // has restricted inputs. 50 | Choices []string `json:"choices"` 51 | // Help contains the help text for the option. 52 | Help string `json:"help,omitempty"` 53 | // Hidden is true if the option is not returned in the help output (but can 54 | // still be provided). 55 | Hidden bool `json:"hidden"` 56 | // MetaArgs are the simplified syntax for the option, if any. 57 | MetaArgs string `json:"meta_args,omitempty"` 58 | // Type is the type (string, int, float64, bool, etc) of the option. 59 | Type string `json:"type"` 60 | // LongFlags are the extended flags for the option (e.g. --version). 61 | LongFlags []string `json:"long_flags"` 62 | // ShortFlags are the shortened flags for the option (e.g. -v). 63 | ShortFlags []string `json:"short_flags"` 64 | // NArgs is the number of arguments the option accepts. 65 | NArgs int `json:"nargs"` 66 | } 67 | 68 | type OptionURL struct { 69 | // Name is the name of the option link. 70 | Name string `json:"name"` 71 | // URL is the link to the documentation for the option. 72 | URL string `json:"url"` 73 | } 74 | -------------------------------------------------------------------------------- /cmd/codegen/go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= 2 | dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= 3 | github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= 4 | github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 5 | github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= 6 | github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 7 | github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= 8 | github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 12 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 13 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 14 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 15 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 16 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 17 | github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= 18 | github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 19 | github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= 20 | github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= 21 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 22 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 23 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 24 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 25 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 26 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 27 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 28 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 29 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 30 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 31 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 32 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 33 | github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= 34 | github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 35 | github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= 36 | github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= 37 | github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= 38 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 39 | golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= 40 | golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 41 | gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= 42 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | # THIS FILE IS GENERATED! DO NOT EDIT! Maintained by Terraform. 2 | name: "🐞 Submit a bug report" 3 | description: Create a report to help us improve! 4 | title: "bug: [REPLACE ME]" 5 | labels: 6 | - bug 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | ### Thanks for submitting a bug report to **go-ytdlp**! 📋 12 | 13 | - 💬 Make sure to check out the [**discussions**](../discussions) section. If your issue isn't a bug (or you're not sure), and you're looking for help to solve it, please [start a discussion here](../discussions/new?category=q-a) first. 14 | - 🔎 Please [**search**](../labels/bug) to see if someone else has submitted a similar bug report, before making a new report. 15 | 16 | ---------------------------------------- 17 | - type: textarea 18 | id: description 19 | attributes: 20 | label: "🌧 Describe the problem" 21 | description: A clear and concise description of what the problem is. 22 | placeholder: 'Example: "When I attempted to do X, I got X error"' 23 | validations: 24 | required: true 25 | - type: textarea 26 | id: expected 27 | attributes: 28 | label: "⛅ Expected behavior" 29 | description: A clear and concise description of what you expected to happen. 30 | placeholder: 'Example: "I expected X to let me add Y component, and be successful"' 31 | validations: 32 | required: true 33 | - type: textarea 34 | id: reproduce 35 | attributes: 36 | label: "🔄 Minimal reproduction" 37 | description: >- 38 | Steps to reproduce the behavior (including code examples and/or 39 | configuration files if necessary) 40 | placeholder: >- 41 | Example: "1. Click on '....' | 2. Run command with flags --foo --bar, 42 | etc | 3. See error" 43 | - type: input 44 | id: version 45 | attributes: 46 | label: "💠 Version: go-ytdlp" 47 | description: What version of go-ytdlp is being used? 48 | placeholder: 'Examples: "v1.2.3, master branch, commit 1a2b3c"' 49 | validations: 50 | required: true 51 | - type: dropdown 52 | id: os 53 | attributes: 54 | label: "🖥 Version: Operating system" 55 | description: >- 56 | What operating system did this issue occur on (if other, specify in 57 | "Additional context" section)? 58 | options: 59 | - linux/ubuntu 60 | - linux/debian 61 | - linux/centos 62 | - linux/alpine 63 | - linux/other 64 | - windows/10 65 | - windows/11 66 | - windows/other 67 | - macos 68 | - other 69 | validations: 70 | required: true 71 | - type: textarea 72 | id: context 73 | attributes: 74 | label: "⚙ Additional context" 75 | description: >- 76 | Add any other context about the problem here. This includes things 77 | like logs, screenshots, code examples, what was the state when the 78 | bug occurred? 79 | placeholder: > 80 | Examples: "logs, code snippets, screenshots, os/browser version info, 81 | etc" 82 | - type: checkboxes 83 | id: requirements 84 | attributes: 85 | label: "🤝 Requirements" 86 | description: "Please confirm the following:" 87 | options: 88 | - label: >- 89 | I believe the problem I'm facing is a bug, and is not intended 90 | behavior. [Post here if you're not sure](../discussions/new?category=q-a). 91 | required: true 92 | - label: >- 93 | I have confirmed that someone else has not 94 | [submitted a similar bug report](../labels/bug). 95 | required: true 96 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | # THIS FILE IS GENERATED! DO NOT EDIT! Maintained by Terraform. 2 | name: "💡 Submit a feature request" 3 | description: Suggest an awesome feature for this project! 4 | title: "feature: [REPLACE ME]" 5 | labels: 6 | - enhancement 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | ### Thanks for submitting a feature request! 📋 12 | 13 | - 💬 Make sure to check out the [**discussions**](../discussions) section of this repository. Do you have an idea for an improvement, but want to brainstorm it with others first? [Start a discussion here](../discussions/new?category=ideas) first. 14 | - 🔎 Please [**search**](../labels/enhancement) to see if someone else has submitted a similar feature request, before making a new request. 15 | 16 | --------------------------------------------- 17 | - type: textarea 18 | id: describe 19 | attributes: 20 | label: "✨ Describe the feature you'd like" 21 | description: >- 22 | A clear and concise description of what you want to happen, or what 23 | feature you'd like added. 24 | placeholder: 'Example: "It would be cool if X had support for Y"' 25 | validations: 26 | required: true 27 | - type: textarea 28 | id: related 29 | attributes: 30 | label: "🌧 Is your feature request related to a problem?" 31 | description: >- 32 | A clear and concise description of what the problem is. 33 | placeholder: >- 34 | Example: "I'd like to see X feature added, as I frequently have to do Y, 35 | and I think Z would solve that problem" 36 | - type: textarea 37 | id: alternatives 38 | attributes: 39 | label: "🔎 Describe alternatives you've considered" 40 | description: >- 41 | A clear and concise description of any alternative solutions or features 42 | you've considered. 43 | placeholder: >- 44 | Example: "I've considered X and Y, however the potential problems with 45 | those solutions would be [...]" 46 | validations: 47 | required: true 48 | - type: dropdown 49 | id: breaking 50 | attributes: 51 | label: "⚠ If implemented, do you think this feature will be a breaking change to users?" 52 | description: >- 53 | To the best of your ability, do you think implementing this change 54 | would impact users in a way during an upgrade process? 55 | options: 56 | - "Yes" 57 | - "No" 58 | - "Not sure" 59 | validations: 60 | required: true 61 | - type: textarea 62 | id: context 63 | attributes: 64 | label: "⚙ Additional context" 65 | description: >- 66 | Add any other context or screenshots about the feature request here 67 | (attach if necessary). 68 | placeholder: "Examples: logs, screenshots, etc" 69 | - type: checkboxes 70 | id: requirements 71 | attributes: 72 | label: "🤝 Requirements" 73 | description: "Please confirm the following:" 74 | options: 75 | - label: >- 76 | I have confirmed that someone else has not 77 | [submitted a similar feature request](../labels/enhancement). 78 | required: true 79 | - label: >- 80 | If implemented, I believe this feature will help others, in 81 | addition to solving my problems. 82 | required: true 83 | - label: I have looked into alternative solutions to the best of my ability. 84 | required: true 85 | - label: >- 86 | (optional) I would be willing to contribute to testing this 87 | feature if implemented, or making a PR to implement this 88 | functionality. 89 | required: false 90 | -------------------------------------------------------------------------------- /results_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package ytdlp 6 | 7 | import ( 8 | "context" 9 | "net/http" 10 | "net/http/httptest" 11 | "path/filepath" 12 | "strings" 13 | "testing" 14 | 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | type mockServer struct { 20 | *httptest.Server 21 | 22 | fileURL string 23 | } 24 | 25 | func newMockServer(t *testing.T, fileName string) *mockServer { 26 | t.Helper() 27 | 28 | base := filepath.Base(fileName) 29 | 30 | // TODO: potentially replace with FileServer + Go 1.24 os.Root. 31 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | if strings.HasSuffix(r.URL.Path, base) { 33 | http.ServeFile(w, r, fileName) 34 | return 35 | } 36 | 37 | w.WriteHeader(http.StatusNotFound) 38 | _, _ = w.Write([]byte("not found")) 39 | })) 40 | t.Cleanup(server.Close) 41 | 42 | return &mockServer{ 43 | Server: server, 44 | fileURL: server.URL + "/" + base, 45 | } 46 | } 47 | 48 | func TestExtractedInfo(t *testing.T) { 49 | server := newMockServer(t, "testdata/sample-1.mp4") 50 | 51 | dir := t.TempDir() 52 | 53 | result, err := New(). 54 | ForceOverwrites(). 55 | Output(filepath.Join(dir, "%(extractor)s - %(title)s.%(ext)s")). 56 | PrintJSON(). 57 | Run(context.TODO(), server.fileURL) 58 | if err != nil { 59 | t.Fatal(err) 60 | return 61 | } 62 | 63 | info, err := result.GetExtractedInfo() 64 | if err != nil { 65 | t.Fatal(err) 66 | } 67 | 68 | require.Len(t, info, 1, "expected 1 extracted info") 69 | require.NotNil(t, info[0].FormatID, "expected format id to be set") 70 | assert.Equal(t, "mp4", *info[0].FormatID, "expected format id to be mp4") 71 | 72 | require.NotNil(t, info[0].Protocol, "expected protocol to be set") 73 | assert.Equal(t, "http", *info[0].Protocol, "expected protocol to be http") 74 | 75 | require.NotNil(t, info[0].HTTPHeaders, "expected http headers to be set") 76 | assert.Contains(t, info[0].HTTPHeaders["User-Agent"], "Mozilla", "expected User-Agent header to be set and contain Mozilla") 77 | 78 | assert.Equal(t, "sample-1", info[0].ID, "expected id to be set") 79 | 80 | require.NotNil(t, info[0].Title, "expected title to be set") 81 | assert.Equal(t, "sample-1", *info[0].Title, "expected title to be set") 82 | 83 | require.Len(t, info[0].Formats, 1, "expected 1 format") 84 | require.NotNil(t, info[0].Formats[0].Extension, "expected format extension to be set") 85 | assert.Equal(t, "mp4", *info[0].Formats[0].Extension, "expected format extension to be mp4") 86 | 87 | require.NotNil(t, info[0].URL, "expected url to be set") 88 | assert.Equal(t, server.fileURL, *info[0].URL, "expected url to be set") 89 | require.NotNil(t, info[0].WebpageURL, "expected webpage url to be set") 90 | assert.Equal(t, server.fileURL, *info[0].WebpageURL, "expected webpage url to be set") 91 | 92 | require.NotNil(t, info[0].Filename, "expected filename to be set") 93 | assert.FileExists(t, *info[0].Filename, "expected file to exist") 94 | 95 | require.NotNil(t, info[0].Timestamp, "expected timestamp to be set") 96 | assert.Positive(t, *info[0].Timestamp, "expected timestamp to be set") 97 | 98 | require.NotNil(t, info[0].UploadDate, "expected upload date to be set") 99 | assert.Positive(t, *info[0].UploadDate, "expected upload date to be set") 100 | 101 | require.NotNil(t, info[0].Extractor, "expected extractor to be set") 102 | assert.Equal(t, "generic", *info[0].Extractor, "expected extractor to be generic") 103 | 104 | require.NotNil(t, info[0].ExtractorKey, "expected extractor key to be set") 105 | assert.Equal(t, "Generic", *info[0].ExtractorKey, "expected extractor key to be generic") 106 | } 107 | -------------------------------------------------------------------------------- /cmd/codegen/templates/optiondata.gotmpl: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | // 5 | // Code generated by cmd/codegen. DO NOT EDIT. 6 | 7 | package optiondata 8 | 9 | // Groups contains is a list of all the option groups. 10 | var Groups = []*OptionGroup{ 11 | {{- range $group := .OptionGroups }} 12 | group{{ $group.Name | to_camel }}, 13 | {{- end }} 14 | } 15 | 16 | // Underlying option groups. 17 | var ( 18 | {{- range $group := .OptionGroups }} 19 | group{{ $group.Name | to_camel }} = &OptionGroup{ 20 | Name: {{ $group.Name | quote }}, 21 | {{- if $group.Description }} 22 | Description: {{ $group.Description | quote }}, 23 | {{- end }} 24 | Options: []*Option{ 25 | {{- range $option := $group.Options }} 26 | option{{ $option.Name | to_camel }}, 27 | {{- end }} 28 | }, 29 | } 30 | {{- end }} 31 | ) 32 | 33 | // Options contains a list of all options. 34 | var Options = []*Option{ 35 | {{- range $group := .OptionGroups }} 36 | {{- range $option := $group.Options }} 37 | option{{ $option.Name | to_camel }}, 38 | {{- end }} 39 | {{- end }} 40 | } 41 | 42 | // Underlying options. 43 | var ( 44 | {{- range $group := .OptionGroups }} 45 | {{- range $option := $group.Options }} 46 | option{{ $option.Name | to_camel }} = &Option{ 47 | {{- if $option.ID }} 48 | ID: {{ $option.ID | quote }}, 49 | {{- end }} 50 | Name: {{ $option.Name | quote }}, 51 | NameCamelCase: {{ $option.Name | to_lower_camel | quote }}, 52 | NamePascalCase: {{ $option.Name | to_camel | quote }}, 53 | NameSnakeCase: {{ $option.Name | to_snake | quote }}, 54 | {{- if $option.URLs }} 55 | URLs: []*OptionURL{ 56 | {{- range $url := $option.URLs }} 57 | { 58 | Name: {{ $url.Name | quote }}, 59 | URL: {{ $url.URL | quote }}, 60 | }, 61 | {{- end }} 62 | }, 63 | {{- end }} 64 | DefaultFlag: {{ $option.Flag | quote }}, 65 | {{- if and (gt $option.NArgs 0) ($option.ArgNames) }} 66 | ArgNames: []string{ {{- range $arg := $option.ArgNames }}{{ $arg | quote }},{{- end }} }, 67 | {{- end }} 68 | Executable: {{ if $option.Executable }}true{{ else }}false{{ end }}, 69 | {{- if $option.Deprecated }} 70 | Deprecated: {{ $option.Deprecated | quote }}, 71 | {{- end }} 72 | {{- if $option.Choices }} 73 | Choices: []string{ {{- range $choice := $option.Choices }}{{ $choice | quote }},{{- end }} }, 74 | {{- end }} 75 | {{- if $option.Help }} 76 | Help: {{ $option.Help | quote}}, 77 | {{- end }} 78 | {{- if $option.Hidden }} 79 | Hidden: true, 80 | {{- end }} 81 | {{- if $option.MetaArgs }} 82 | MetaArgs: {{ $option.MetaArgs | quote }}, 83 | {{- end }} 84 | Type: {{ $option.Type | quote }}, 85 | {{- if $option.LongFlags }} 86 | LongFlags: []string{ {{- range $flag := $option.LongFlags }}{{ $flag | quote }},{{- end }} }, 87 | {{- end }} 88 | {{- if $option.ShortFlags }} 89 | ShortFlags: []string{ {{- range $flag := $option.ShortFlags }}{{ $flag | quote }},{{- end }} }, 90 | {{- end }} 91 | {{- if $option.NArgs }} 92 | NArgs: {{ $option.NArgs }}, 93 | {{- end }} 94 | } 95 | {{- end }} 96 | {{- end }} 97 | ) 98 | -------------------------------------------------------------------------------- /cmd/patch-ytdlp/export-options.patch: -------------------------------------------------------------------------------- 1 | diff --git a/yt_dlp/options.py b/yt_dlp/options.py 2 | index 76d401c..5dcfcb8 100644 3 | --- a/yt_dlp/options.py 4 | +++ b/yt_dlp/options.py 5 | @@ -2,2 +2,3 @@ 6 | import contextlib 7 | +import json 8 | import optparse 9 | @@ -294,2 +295,83 @@ def _dict_from_options_callback( 10 | 11 | + def _export_options_callback(option, opt_str, value, parser: _YoutubeDLOptionParser): 12 | + from .extractor import list_extractor_classes 13 | + from .extractor.generic import GenericIE # Importing GenericIE is currently slow since it imports YoutubeIE 14 | + 15 | + extractors = [] 16 | + 17 | + for ie in list_extractor_classes(): 18 | + extractors.append({ 19 | + "name": ie.IE_NAME, 20 | + "description": ie.description(markdown=False), 21 | + "broken": not ie.working(), 22 | + "age_limit": ie.age_limit or None, 23 | + }) 24 | + 25 | + data = { 26 | + 'option_groups': [], 27 | + "extractors": extractors, 28 | + 'channel': CHANNEL, 29 | + 'version': __version__, 30 | + } 31 | + 32 | + for group in parser.option_groups: 33 | + group_data = { 34 | + 'name': group.title, 35 | + 'description': group.description, 36 | + 'options': [] 37 | + } 38 | + for option in group.option_list: 39 | + if option.dest == parser.ALIAS_DEST: 40 | + continue 41 | + 42 | + default = option.default 43 | + 44 | + # if default isn't serializable, try to convert it to a type that is. 45 | + if default == optparse.NO_DEFAULT: 46 | + default = None 47 | + elif not isinstance(default, (str, int, float, bool, list, dict, type(None))): 48 | + try: 49 | + default = str(default) 50 | + except Exception: 51 | + default = None 52 | + 53 | + option_data = { 54 | + 'id': option.dest, 55 | + 'action': str(option.action), 56 | + 'choices': list(option.choices) if option.choices else None, 57 | + 'help': option.help if option.help != optparse.SUPPRESS_HELP else None, 58 | + 'hidden': option.help == optparse.SUPPRESS_HELP, 59 | + 'meta_args': option.metavar, 60 | + 'type': str(option.type) if option.type else None, 61 | + 'long_flags': option._long_opts, 62 | + 'short_flags': option._short_opts, 63 | + 'nargs': option.nargs if option.nargs and option.nargs > 0 and option.takes_value() else 0, 64 | + 'default_value': default, 65 | + 'const_value': option.const, 66 | + } 67 | + 68 | + if not option_data['id'] or option_data['id'] == '_': 69 | + if option._long_opts: 70 | + option_data['id'] = option._long_opts[-1].lstrip('-') 71 | + elif option.callback: 72 | + option_data['id'] = option.callback.__name__.lstrip('_') 73 | + 74 | + if not option_data['type']: 75 | + if option.action == 'store_true' or option.action == 'store_false': 76 | + option_data['type'] = 'bool' 77 | + elif option.nargs and option.nargs > 0: 78 | + option_data['type'] = 'string' 79 | + elif option_data['type'] == 'choice': 80 | + option_data['type'] = 'string' 81 | + 82 | + # if help output contains %default, replace it with the actual default value. 83 | + if option_data['help'] and '%default' in option_data['help']: 84 | + option_data['help'] = option_data['help'].replace('%default', str(option_data['default_value'])) 85 | + 86 | + group_data['options'].append(option_data) 87 | + data['option_groups'].append(group_data) 88 | + 89 | + print(json.dumps(data, indent=4)) 90 | + sys.exit() 91 | + 92 | def when_prefix(default): 93 | @@ -352,2 +434,6 @@ def _preset_alias_callback(option, opt_str, value, parser): 94 | general = optparse.OptionGroup(parser, 'General Options') 95 | + general.add_option( 96 | + '--export-options', 97 | + action='callback', default=False, callback=_export_options_callback, 98 | + help=optparse.SUPPRESS_HELP) 99 | general.add_option( 100 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package ytdlp 6 | 7 | import ( 8 | "errors" 9 | "fmt" 10 | "os/exec" 11 | "strings" 12 | ) 13 | 14 | func wrapError(r *Result, err error) (*Result, error) { 15 | if err == nil { 16 | return r, nil 17 | } 18 | 19 | if r == nil { 20 | return nil, &ErrUnknown{wrapped: err} 21 | } 22 | 23 | err = r.decorateError(err) 24 | 25 | if errors.Is(err, exec.ErrDot) || errors.Is(err, exec.ErrNotFound) { 26 | return r, &ErrMisconfig{wrapped: err, result: r} 27 | } 28 | 29 | if r.ExitCode != 0 { 30 | return r, &ErrExitCode{wrapped: err, result: r} 31 | } 32 | 33 | if strings.Contains(r.Stderr, "error: no such option") { 34 | return r, &ErrParsing{wrapped: err, result: r} 35 | } 36 | 37 | return r, &ErrUnknown{wrapped: err} 38 | } 39 | 40 | // ErrExitCode is returned when the exit code of the yt-dlp process is non-zero. 41 | type ErrExitCode struct { 42 | wrapped error 43 | result *Result 44 | } 45 | 46 | func (e *ErrExitCode) Unwrap() error { 47 | return e.wrapped 48 | } 49 | 50 | func (e *ErrExitCode) Error() string { 51 | return fmt.Sprintf("exit code %d: %s", e.result.ExitCode, e.wrapped) 52 | } 53 | 54 | // IsExitCodeError returns true when the exit code of the yt-dlp process is non-zero. 55 | func IsExitCodeError(err error) (*ErrExitCode, bool) { 56 | var e *ErrExitCode 57 | return e, errors.As(err, &e) 58 | } 59 | 60 | // ErrMisconfig is returned when the yt-dlp executable is not found, or is not 61 | // configured properly. 62 | type ErrMisconfig struct { 63 | wrapped error 64 | result *Result 65 | } 66 | 67 | func (e *ErrMisconfig) Unwrap() error { 68 | return e.wrapped 69 | } 70 | 71 | func (e *ErrMisconfig) Error() string { 72 | return fmt.Sprintf("misconfiguration error (executable: %q): %s", e.result.Executable, e.wrapped) 73 | } 74 | 75 | // IsMisconfigError returns true when the yt-dlp executable is not found, or is not 76 | // configured properly. 77 | func IsMisconfigError(err error) (*ErrMisconfig, bool) { 78 | var e *ErrMisconfig 79 | return e, errors.As(err, &e) 80 | } 81 | 82 | // ErrParsing is returned when the yt-dlp process fails due to an invalid flag or 83 | // argument, possibly due to a version mismatch or go-ytdlp bug. 84 | type ErrParsing struct { 85 | wrapped error 86 | result *Result 87 | } 88 | 89 | func (e *ErrParsing) Unwrap() error { 90 | return e.wrapped 91 | } 92 | 93 | func (e *ErrParsing) Error() string { 94 | return fmt.Sprintf( 95 | "parsing error (yt-dlp version might be too different, go-ytdlp version built with yt-dlp %s/%s): %s", 96 | Channel, 97 | Version, 98 | e.wrapped, 99 | ) 100 | } 101 | 102 | // IsParsingError returns true when the yt-dlp process fails due to an invalid flag or 103 | // argument, possibly due to a version mismatch or go-ytdlp bug. 104 | func IsParsingError(err error) (*ErrParsing, bool) { 105 | var e *ErrParsing 106 | return e, errors.As(err, &e) 107 | } 108 | 109 | // ErrUnknown is returned when the error is unknown according to go-ytdlp. 110 | type ErrUnknown struct { 111 | wrapped error 112 | } 113 | 114 | func (e *ErrUnknown) Unwrap() error { 115 | return e.wrapped 116 | } 117 | 118 | func (e *ErrUnknown) Error() string { 119 | return e.wrapped.Error() 120 | } 121 | 122 | // IsUnknownError returns true when the error is unknown according to go-ytdlp. 123 | func IsUnknownError(err error) (*ErrUnknown, bool) { 124 | var e *ErrUnknown 125 | return e, errors.As(err, &e) 126 | } 127 | 128 | type ErrJSONParsingFlag struct { 129 | ID string `json:"id,omitempty"` 130 | JSONPath string `json:"json_path,omitempty"` 131 | Flag string `json:"flag,omitempty"` 132 | Err error `json:"error,omitempty"` 133 | } 134 | 135 | func (e *ErrJSONParsingFlag) Unwrap() error { 136 | return e.Err 137 | } 138 | 139 | func (e *ErrJSONParsingFlag) Error() string { 140 | return fmt.Sprintf( 141 | "error while parsing json at path %q (flag: %q, id: %q): %s", 142 | e.JSONPath, 143 | e.Flag, 144 | e.ID, 145 | e.Err, 146 | ) 147 | } 148 | 149 | // IsJSONParsingFlagError returns true when the error is a JSON parsing error. 150 | func IsJSONParsingFlagError(err error) (*ErrJSONParsingFlag, bool) { 151 | var e *ErrJSONParsingFlag 152 | return e, errors.As(err, &e) 153 | } 154 | 155 | type ErrMultipleJSONParsingFlags struct { 156 | Errors []*ErrJSONParsingFlag `json:"errors,omitempty"` 157 | } 158 | 159 | func (e *ErrMultipleJSONParsingFlags) Error() string { 160 | return fmt.Sprintf("multiple errors while parsing json: %s", e.Errors) 161 | } 162 | 163 | func IsMultipleJSONParsingFlagsError(err error) (*ErrMultipleJSONParsingFlags, bool) { 164 | var e *ErrMultipleJSONParsingFlags 165 | return e, errors.As(err, &e) 166 | } 167 | -------------------------------------------------------------------------------- /_examples/http-server/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "encoding/json" 10 | "log/slog" 11 | "net/http" 12 | "os" 13 | "path/filepath" 14 | "time" 15 | 16 | "github.com/lrstanley/go-ytdlp" 17 | sloghttp "github.com/samber/slog-http" 18 | ) 19 | 20 | // Example curl call: 21 | // $ curl -sS \ 22 | // -H "Content-Type: application/json" \ 23 | // --data @example-request-body.json \ 24 | // http://localhost:8080/download 25 | 26 | var downloadsPath = "/tmp/ytdlp-downloads" 27 | 28 | // RequestBody is an example of how you might structure a request. 29 | type RequestBody struct { 30 | Env map[string]string `json:"env,omitempty"` 31 | Flags ytdlp.FlagConfig `json:"flags"` 32 | Args []string `json:"args"` 33 | } 34 | 35 | func main() { 36 | logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) 37 | 38 | logger.Info("creating downloads path", "path", downloadsPath) 39 | if err := os.MkdirAll(downloadsPath, 0o750); err != nil { 40 | logger.Error("failed to create downloads path", "error", err) 41 | os.Exit(1) 42 | } 43 | 44 | mux := http.NewServeMux() 45 | 46 | mux.HandleFunc("/download", postDownload) 47 | 48 | srv := &http.Server{ 49 | Addr: ":8080", 50 | Handler: sloghttp.New(logger)(mux), 51 | ReadTimeout: 120 * time.Second, 52 | WriteTimeout: 120 * time.Second, 53 | } 54 | 55 | logger.Info("starting server", "addr", srv.Addr) 56 | if err := srv.ListenAndServe(); err != nil { 57 | logger.Error("failed to start server", "error", err) 58 | os.Exit(1) 59 | } 60 | } 61 | 62 | func jsonError(w http.ResponseWriter, r *http.Request, code int, err error) { 63 | w.Header().Set("Content-Type", "application/json") 64 | w.WriteHeader(code) 65 | data := map[string]any{ 66 | "error": err.Error(), 67 | "code": code, 68 | } 69 | 70 | if perr, ok := ytdlp.IsMultipleJSONParsingFlagsError(err); ok { 71 | data["errors"] = perr.Errors 72 | } 73 | 74 | if perr, ok := ytdlp.IsJSONParsingFlagError(err); ok { 75 | data["error"] = perr.Err.Error() 76 | data["id"] = perr.ID 77 | data["json_path"] = perr.JSONPath 78 | data["flag"] = perr.Flag 79 | } 80 | 81 | if err := json.NewEncoder(w).Encode(&data); err != nil { 82 | slog.ErrorContext(r.Context(), "failed to encode error", "error", err) 83 | return 84 | } 85 | } 86 | 87 | func postDownload(w http.ResponseWriter, r *http.Request) { 88 | if r.Method != http.MethodPost { 89 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 90 | return 91 | } 92 | 93 | // 1. Unmarshal JSON into RequestBody. 94 | var body RequestBody 95 | if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 96 | http.Error(w, err.Error(), http.StatusBadRequest) 97 | return 98 | } 99 | defer r.Body.Close() 100 | 101 | // 2. Validate flags using [FlagConfig.Validate], or using the JSON schema (noted above). 102 | if err := body.Flags.Validate(); err != nil { 103 | jsonError(w, r, http.StatusBadRequest, err) 104 | return 105 | } 106 | 107 | // 3. Validate user provides at least one positional argument. 108 | if len(body.Args) == 0 { 109 | http.Error(w, "at least one positional argument is required", http.StatusBadRequest) 110 | return 111 | } 112 | 113 | // 4. Use the values to construct and run a yt-dlp command, by calling `Command.SetFlagConfig`, 114 | // `Command.SetEnvVar`, and then `Command.Run`. 115 | cmd := ytdlp.New(). 116 | SetFlagConfig(&body.Flags) 117 | 118 | if body.Flags.Filesystem.Output != nil { 119 | cmd.Output(filepath.Join( 120 | downloadsPath, 121 | *body.Flags.Filesystem.Output, 122 | )) 123 | } else { 124 | cmd.Output(filepath.Join( 125 | downloadsPath, 126 | "%(extractor)s - %(title)s.%(ext)s", 127 | )) 128 | } 129 | 130 | if body.Env != nil { 131 | for k, v := range body.Env { 132 | // You may want to allow-list certain env vars that people can provide, for security reasons. 133 | cmd.SetEnvVar(k, v) 134 | } 135 | } 136 | 137 | // print current run flags. 138 | enc := json.NewEncoder(os.Stdout) 139 | enc.SetIndent("", " ") 140 | err := enc.Encode(cmd.GetFlagConfig()) 141 | if err != nil { 142 | slog.ErrorContext(r.Context(), "failed to encode flags", "error", err) 143 | return 144 | } 145 | 146 | // 5. Run the command. Ideally, this handler would return a response immediately, with another endpoint 147 | // to get the status of the download, and the associated results, as it may take longer to download 148 | // than the user is willing to wait, and/or the timeout value would allow. 149 | result, err := cmd.Run(context.Background(), body.Args...) 150 | if err != nil { 151 | jsonError(w, r, http.StatusUnprocessableEntity, err) 152 | return 153 | } 154 | 155 | w.Header().Set("Content-Type", "application/json") 156 | w.WriteHeader(http.StatusOK) 157 | if err := json.NewEncoder(w).Encode(result); err != nil { 158 | slog.ErrorContext(r.Context(), "failed to encode result", "error", err) 159 | return 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /cmd/codegen/templates/builder.gotmpl: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | // 5 | // Code generated by cmd/codegen. DO NOT EDIT. 6 | 7 | package ytdlp 8 | 9 | import ( 10 | "context" 11 | ) 12 | 13 | func ptr[T any](v T) *T { 14 | return &v 15 | } 16 | 17 | {{- /* variables modified during loops for state checks */}} 18 | {{ $unsets := list }} 19 | 20 | {{ range $group := .OptionGroups }} 21 | {{ range $option := .Options }} 22 | 23 | {{- /* choice logic, where the value of a flag has a list of possible values */}} 24 | {{- if $option.Choices }} 25 | // {{ $option.Name | to_camel }}Option are parameter types for [{{ $option.Name | to_camel }}]. 26 | type {{ $option.Name | to_camel }}Option {{ $option.Type }} 27 | 28 | var ( 29 | {{- range $choice := $option.Choices }} 30 | {{ $option.Name | to_camel }}{{ $choice | to_camel }} {{ $option.Name | to_camel }}Option = {{ if eq $option.Type "string" }}{{ $choice | quote }}{{ else }}{{ $choice }}{{ end }} 31 | {{- end }} 32 | ) 33 | 34 | // All{{ $option.Name | to_camel }}Options are all of the possible values for the {{ $option.Name | to_camel }} option. 35 | var All{{ $option.Name | to_camel }}Options = []{{ $option.Name | to_camel }}Option{ 36 | {{- range $choice := $option.Choices }} 37 | {{ $option.Name | to_camel }}{{ $choice | to_camel }}, 38 | {{- end }} 39 | } 40 | {{- end }}{{/* end if choices */}} 41 | 42 | {{- if $option.Executable }} 43 | {{- /* executable flags, that aren't related to the main functionality of yt-dlp, e.g. --version */}} 44 | {{ template "builder-help" $option }} 45 | func (c *Command) {{ $option.Name | to_camel }}(ctx context.Context, {{ template "builder-meta-args" $option }}) (*Result, error) { 46 | return c.runWithResult(ctx, c.BuildCommand(ctx, 47 | {{- $option.Flag | quote }}, 48 | {{- if and (ne $option.Type "bool") (gt $option.NArgs 0) }} 49 | {{- range $index, $arg := $option.ArgNames -}} 50 | {{ $arg }}, 51 | {{- end }} 52 | {{- end }} 53 | {{- ""}})) 54 | } 55 | {{- else }} 56 | {{ template "builder-help" $option }} 57 | {{- /* setters */}} 58 | func (c *Command) {{ $option.Name | to_camel }}({{ template "builder-meta-args" $option }}) *Command { 59 | {{- range $coption := $group.Options }} 60 | {{- if and (not $coption.Executable) (eq $coption.ID $option.ID) (not (eq $coption.Name $option.Name)) }} 61 | c.flagConfig.{{ $group.Name | to_camel }}.{{ $coption.Name | to_camel }} = nil 62 | {{- end }}{{/* end if eq ID */}} 63 | {{- end }}{{/* end range for options */ -}} 64 | 65 | {{""}} 66 | c.flagConfig.{{ $group.Name | to_camel }}.{{ $option.Name | to_camel }} ={{" " -}} 67 | {{- if $option.AllowsMultiple -}}append( 68 | {{- ""}}c.flagConfig.{{ $group.Name | to_camel }}.{{ $option.Name | to_camel }}, 69 | {{- end }} 70 | {{- if (eq $option.NArgs 0) -}} 71 | ptr(true) 72 | {{- else if (eq $option.NArgs 1) -}} 73 | {{ if not $option.AllowsMultiple }}&{{ end }}{{ $option.ArgNames | join ", " }} 74 | {{- else if (gt $option.NArgs 1) -}} 75 | &Flag{{ $option.Name | to_camel }}{ 76 | {{- range $index, $arg := $option.ArgNames }} 77 | {{ $arg | to_camel }}: {{ $arg }}, 78 | {{- end }} 79 | } 80 | {{- end }} 81 | {{- if $option.AllowsMultiple -}}){{- end }} 82 | return c 83 | } 84 | 85 | {{/* unsetters */}} 86 | {{- $unsetID := ($option.Name | to_camel | trimPrefix "No" | trimPrefix "Yes") }} 87 | {{- if not (has $unsetID $unsets) }} 88 | {{- $unsets = mustAppend $unsets $unsetID }} 89 | 90 | // Unset{{ $unsetID }} unsets any flags that were previously set by one of: 91 | {{- range $coption := $group.Options }} 92 | {{- if $coption.Executable }}{{ continue }}{{- end }} 93 | {{- if eq $unsetID ($coption.Name | to_camel | trimPrefix "No" | trimPrefix "Yes") }} 94 | // - [Command.{{ $coption.Name | to_camel }}] 95 | {{- end }}{{/* end if has suffix */}} 96 | {{- end }}{{/* end range for options */}} 97 | {{- if $option.Deprecated }} 98 | // 99 | // Deprecated: {{ $option.Deprecated }} 100 | {{- end }}{{/* end if deprecated */}} 101 | func (c *Command) Unset{{ $unsetID }}() *Command { 102 | {{- range $coption := $group.Options }} 103 | {{- if and (not $coption.Executable) (eq $coption.ID $option.ID) }} 104 | c.flagConfig.{{ $group.Name | to_camel }}.{{ $coption.Name | to_camel }} = nil 105 | {{- end }}{{/* end if eq ID */}} 106 | {{- end }}{{/* end range for options */}} 107 | return c 108 | } 109 | {{- end }}{{/* end if in $unsets */}} 110 | {{- end }}{{/* end if type */}} 111 | {{ end }}{{/* end range for options */}} 112 | {{ end }}{{/* end range for option groups */}} 113 | -------------------------------------------------------------------------------- /cmd/codegen/option_data.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "log/slog" 9 | "regexp" 10 | "slices" 11 | "strings" 12 | 13 | "github.com/iancoleman/strcase" 14 | ) 15 | 16 | type Extractor struct { 17 | Name string `json:"name"` 18 | Description string `json:"description"` 19 | AgeLimit int `json:"age_limit"` 20 | } 21 | 22 | type OptionURL struct { 23 | Name string 24 | URL string 25 | } 26 | 27 | type OptionData struct { 28 | Channel string `json:"channel"` 29 | Version string `json:"version"` 30 | OptionGroups []OptionGroup `json:"option_groups"` 31 | Extractors []Extractor `json:"extractors"` 32 | } 33 | 34 | func (c *OptionData) Generate() { 35 | for i := range c.OptionGroups { 36 | c.OptionGroups[i].Generate(c) 37 | slog.Info("generated option group", "group", c.OptionGroups[i].Name) 38 | } 39 | } 40 | 41 | type OptionGroup struct { 42 | // Generated fields. 43 | Parent *OptionData `json:"-"` // Reference to parent. 44 | Name string `json:"-"` 45 | 46 | // Command data fields. 47 | OriginalName string `json:"name"` 48 | Description string `json:"description"` 49 | Options []Option `json:"options"` 50 | } 51 | 52 | func (o *OptionGroup) Generate(parent *OptionData) { 53 | o.Parent = parent 54 | o.Name = optionGroupReplacer.Replace(o.OriginalName) 55 | 56 | for i := range o.Options { 57 | o.Options[i].Generate(o) 58 | slog.Info("generated option", "option", o.Options[i].Flag) 59 | } 60 | 61 | // Remove any ignored flags. 62 | o.Options = slices.DeleteFunc(o.Options, func(o Option) bool { 63 | return slices.Contains(ignoredFlags, o.Flag) 64 | }) 65 | } 66 | 67 | func (o *OptionGroup) AllAllowsMultiple() (opts []*Option) { 68 | for _, o := range o.Options { 69 | if o.AllowsMultiple { 70 | opts = append(opts, &o) 71 | } 72 | } 73 | return opts 74 | } 75 | 76 | type Option struct { 77 | // Generated fields. 78 | Parent *OptionGroup `json:"-"` // Reference to parent. 79 | Name string `json:"-"` // simplified name, based off the first found flags. 80 | Flag string `json:"-"` // first flag (priority on long flags). 81 | AllFlags []string `json:"-"` // all flags, short + long. 82 | ArgNames []string `json:"-"` // MetaArgs converted to function arguments. 83 | Executable bool `json:"-"` // if the option means yt-dlp doesn't accept arguments, and some callback is done. 84 | Deprecated string `json:"-"` // if the option is deprecated, this will be the deprecation description. 85 | URLs []OptionURL `json:"-"` // if the option has any links to the documentation. 86 | AllowsMultiple bool `json:"-"` // if the option allows being invoked multiple times. 87 | 88 | // Command data fields. 89 | ID string `json:"id"` 90 | Action string `json:"action"` 91 | Choices []string `json:"choices"` 92 | Help string `json:"help"` 93 | Hidden bool `json:"hidden"` 94 | MetaArgs string `json:"meta_args"` 95 | Type string `json:"type"` 96 | LongFlags []string `json:"long_flags"` 97 | ShortFlags []string `json:"short_flags"` 98 | NArgs int `json:"nargs"` 99 | DefaultValue any `json:"default_value"` 100 | Const any `json:"const_value"` 101 | } 102 | 103 | var ( 104 | reMetaArgsStrip = regexp.MustCompile(`\[.*\]`) 105 | reRemoveAlias = regexp.MustCompile(`\s+\(Alias:.*\)`) 106 | ) 107 | 108 | func (o *Option) Generate(parent *OptionGroup) { 109 | o.Parent = parent 110 | o.AllFlags = append(o.ShortFlags, o.LongFlags...) //nolint:gocritic 111 | 112 | if len(o.LongFlags) > 0 { 113 | o.Name = strings.TrimPrefix(o.LongFlags[0], "--") 114 | o.Flag = o.LongFlags[0] 115 | } else if len(o.ShortFlags) > 0 { 116 | o.Name = strings.TrimPrefix(o.ShortFlags[0], "-") 117 | o.Flag = o.ShortFlags[0] 118 | } 119 | 120 | if slices.Contains(knownExecutable, o.ID) || slices.Contains(knownExecutable, o.Flag) { 121 | o.Executable = true 122 | } 123 | 124 | if strings.Contains(o.Help, "used multiple times") || strings.Contains(o.Help, "option multiple times") { 125 | o.AllowsMultiple = true 126 | } 127 | 128 | for _, d := range deprecatedFlags { 129 | if strings.EqualFold(d[0], o.ID) || strings.EqualFold(d[0], o.Flag) { 130 | o.Deprecated = d[1] 131 | } 132 | } 133 | 134 | switch o.Type { 135 | case "choice": 136 | o.Type = "string" 137 | case "float": 138 | o.Type = "float64" 139 | case "": 140 | if o.NArgs == 0 { 141 | o.Type = "bool" 142 | } else { 143 | o.Type = "string" 144 | } 145 | } 146 | 147 | // Clean up help text. 148 | o.Help = reRemoveAlias.ReplaceAllString(o.Help, "") 149 | 150 | // Clean up [prefix:] syntax from MetaArgs, since we don't care about the optional prefix type. 151 | meta := reMetaArgsStrip.ReplaceAllString(o.MetaArgs, "") 152 | 153 | if slices.Contains(disallowedNames, meta) { 154 | meta = "value" 155 | } 156 | 157 | // Convert MetaArgs to function arguments. 158 | for _, v := range strings.Split(meta, " ") { 159 | o.ArgNames = append(o.ArgNames, strcase.ToLowerCamel(strings.ToLower(v))) 160 | } 161 | 162 | // URLs. 163 | if urls, ok := linkableFlags[o.Flag]; ok { 164 | for _, u := range urls { 165 | o.URLs = append(o.URLs, OptionURL{ 166 | Name: u.Name, 167 | URL: strings.ReplaceAll(u.URL, "{version}", parent.Parent.Version), 168 | }) 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | 2 | # Code of Conduct 3 | 4 | ## Our Pledge :purple_heart: 5 | 6 | We as members, contributors, and leaders pledge to make participation in our 7 | community a harassment-free experience for everyone, regardless of age, body 8 | size, visible or invisible disability, ethnicity, sex characteristics, gender 9 | identity and expression, level of experience, education, socio-economic status, 10 | nationality, personal appearance, race, caste, color, religion, or sexual 11 | identity and orientation. 12 | 13 | We pledge to act and interact in ways that contribute to an open, welcoming, 14 | diverse, inclusive, and healthy community. 15 | 16 | ## Our Standards 17 | 18 | Examples of behavior that contributes to a positive environment for our 19 | community include: 20 | 21 | * Demonstrating empathy and kindness toward other people 22 | * Being respectful of differing opinions, viewpoints, and experiences 23 | * Giving and gracefully accepting constructive feedback 24 | * Accepting responsibility and apologizing to those affected by our mistakes, 25 | and learning from the experience 26 | * Focusing on what is best not just for us as individuals, but for the overall 27 | community 28 | 29 | Examples of unacceptable behavior include: 30 | 31 | * The use of sexualized language or imagery, and sexual attention or advances of 32 | any kind 33 | * Trolling, insulting or derogatory comments, and personal or political attacks 34 | * Public or private harassment 35 | * Publishing others' private information, such as a physical or email address, 36 | without their explicit permission 37 | * Other conduct which could reasonably be considered inappropriate in a 38 | professional setting 39 | 40 | ## Enforcement Responsibilities 41 | 42 | Community leaders are responsible for clarifying and enforcing our standards of 43 | acceptable behavior and will take appropriate and fair corrective action in 44 | response to any behavior that they deem inappropriate, threatening, offensive, 45 | or harmful. 46 | 47 | Community leaders have the right and responsibility to remove, edit, or reject 48 | comments, commits, code, wiki edits, issues, and other contributions that are 49 | not aligned to this Code of Conduct, and will communicate reasons for moderation 50 | decisions when appropriate. 51 | 52 | ## Scope 53 | 54 | This Code of Conduct applies within all community spaces, and also applies when 55 | an individual is officially representing the community in public spaces. 56 | Examples of representing our community include using an official e-mail address, 57 | posting via an official social media account, or acting as an appointed 58 | representative at an online or offline event. 59 | 60 | ## Enforcement 61 | 62 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 63 | reported to the community leaders responsible for enforcement at 64 | disclosure@liam.sh. All complaints will be reviewed and investigated 65 | promptly and fairly. 66 | 67 | All community leaders are obligated to respect the privacy and security of the 68 | reporter of any incident. 69 | 70 | ## Enforcement Guidelines 71 | 72 | Community leaders will follow these Community Impact Guidelines in determining 73 | the consequences for any action they deem in violation of this Code of Conduct: 74 | 75 | ### 1. Correction 76 | 77 | **Community Impact**: Use of inappropriate language or other behavior deemed 78 | unprofessional or unwelcome in the community. 79 | 80 | **Consequence**: A private, written warning from community leaders, providing 81 | clarity around the nature of the violation and an explanation of why the 82 | behavior was inappropriate. A public apology may be requested. 83 | 84 | ### 2. Warning 85 | 86 | **Community Impact**: A violation through a single incident or series of 87 | actions. 88 | 89 | **Consequence**: A warning with consequences for continued behavior. No 90 | interaction with the people involved, including unsolicited interaction with 91 | those enforcing the Code of Conduct, for a specified period of time. This 92 | includes avoiding interactions in community spaces as well as external channels 93 | like social media. Violating these terms may lead to a temporary or permanent 94 | ban. 95 | 96 | ### 3. Temporary Ban 97 | 98 | **Community Impact**: A serious violation of community standards, including 99 | sustained inappropriate behavior. 100 | 101 | **Consequence**: A temporary ban from any sort of interaction or public 102 | communication with the community for a specified period of time. No public or 103 | private interaction with the people involved, including unsolicited interaction 104 | with those enforcing the Code of Conduct, is allowed during this period. 105 | Violating these terms may lead to a permanent ban. 106 | 107 | ### 4. Permanent Ban 108 | 109 | **Community Impact**: Demonstrating a pattern of violation of community 110 | standards, including sustained inappropriate behavior, harassment of an 111 | individual, or aggression toward or disparagement of classes of individuals. 112 | 113 | **Consequence**: A permanent ban from any sort of public interaction within the 114 | community. 115 | 116 | ## Attribution 117 | 118 | This Code of Conduct is adapted from the Contributor Covenant, 119 | version 2.1, available [here](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). 120 | 121 | For answers to common questions about this code of conduct, see the [FAQ](https://www.contributor-covenant.org/faq). 122 | Translations are available at [translations](https://www.contributor-covenant.org/translations). 123 | -------------------------------------------------------------------------------- /cmd/codegen/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "encoding/json" 9 | "fmt" 10 | "log/slog" 11 | "maps" 12 | "os" 13 | "path/filepath" 14 | "reflect" 15 | "strings" 16 | "text/template" 17 | 18 | "github.com/Masterminds/sprig/v3" 19 | "github.com/iancoleman/strcase" 20 | ) 21 | 22 | var ( 23 | funcMap = mergeFuncMaps( 24 | sprig.TxtFuncMap(), // http://masterminds.github.io/sprig/ 25 | template.FuncMap{ 26 | "last": func(x int, a interface{}) bool { 27 | return x == reflect.ValueOf(a).Len()-1 // if last index. 28 | }, 29 | // https://github.com/iancoleman/strcase?tab=readme-ov-file#example 30 | "to_camel": func(s string) string { 31 | return acronymReplacer.Replace(strcase.ToCamel(s)) 32 | }, // AnyKindOfString 33 | "to_lower_camel": func(s string) string { 34 | return acronymReplacer.Replace(strcase.ToLowerCamel(s)) 35 | }, // anyKindOfString 36 | "to_snake": func(s string) string { 37 | return strcase.ToSnake(s) 38 | }, // any_kind_of_string 39 | "has_prefix": func(s, prefix string) bool { 40 | return strings.HasPrefix(s, prefix) 41 | }, 42 | "has_suffix": func(s, suffix string) bool { 43 | return strings.HasSuffix(s, suffix) 44 | }, 45 | }, 46 | ) 47 | 48 | constantsTmpl = template.Must( 49 | template.New("constants.gotmpl"). 50 | Funcs(funcMap). 51 | ParseFiles("./templates/constants.gotmpl"), 52 | ) 53 | 54 | builderTmpl = template.Must( 55 | template.New("builder.gotmpl"). 56 | Funcs(funcMap). 57 | ParseGlob("./templates/builder*.gotmpl"), 58 | ) 59 | 60 | builderTestTmpl = template.Must( 61 | template.New("buildertest.gotmpl"). 62 | Funcs(funcMap). 63 | ParseGlob("./templates/builder*.gotmpl"), 64 | ) 65 | 66 | commandJSONTmpl = template.Must( 67 | template.New("command_json.gen.gotmpl"). 68 | Funcs(funcMap). 69 | ParseGlob("./templates/command_json*.gotmpl"), 70 | ) 71 | 72 | optionDataTmpl = template.Must( 73 | template.New("optiondata.gotmpl"). 74 | Funcs(funcMap). 75 | ParseGlob("./templates/optiondata*.gotmpl"), 76 | ) 77 | 78 | optionGroupReplacer = strings.NewReplacer( 79 | " Options", "", 80 | " and ", " ", 81 | ) 82 | 83 | // TODO: can be replaced when this is supported: https://github.com/iancoleman/strcase/issues/13 84 | acronymReplacer = strings.NewReplacer( 85 | "Api", "API", 86 | "Https", "HTTPS", 87 | "Http", "HTTP", 88 | "Id", "ID", 89 | "Json", "JSON", 90 | "Html", "HTML", 91 | "Xml", "XML", 92 | "Ascii", "ASCII", 93 | "Cpu", "CPU", 94 | "Dns", "DNS", 95 | "Ip", "IP", 96 | "Tls", "TLS", 97 | "Tcp", "TCP", 98 | "Ttl", "TTL", 99 | "Uuid", "UUID", 100 | "Uid", "UID", 101 | "Uri", "URI", 102 | "Url", "URL", 103 | "Xxs", "XXS", 104 | "Xff", "XFF", 105 | "Ffmpeg", "FFmpeg", 106 | "Avconv", "AVConv", 107 | "Mpegts", "MPEGTS", 108 | "mpegts", "mpegTS", 109 | "Mpeg", "MPEG", 110 | "Mpd", "MPD", 111 | "Mso", "MSO", 112 | "Cn", "CN", 113 | "Hls", "HLS", 114 | "Autonumber", "AutoNumber", 115 | "autonumber", "autoNumber", 116 | "Datebefore", "DateBefore", 117 | "Dateafter", "DateAfter", 118 | "datebefore", "dateBefore", 119 | "dateafter", "dateAfter", 120 | "Twofactor", "TwoFactor", 121 | "twofactor", "twoFactor", 122 | "Postprocessor", "PostProcessor", 123 | "postprocessor", "postProcessor", 124 | "Filesize", "FileSize", 125 | "filesize", "fileSize", 126 | ) 127 | ) 128 | 129 | func mergeFuncMaps(fm ...template.FuncMap) template.FuncMap { 130 | out := template.FuncMap{} 131 | for _, m := range fm { 132 | maps.Copy(out, m) 133 | } 134 | return out 135 | } 136 | 137 | func createTemplateFile(dir, name string, tmpl *template.Template, data any) { 138 | slog.Info("creating template file", "file", name) 139 | 140 | err := os.MkdirAll(dir, 0o755) 141 | if err != nil { 142 | panic(err) 143 | } 144 | 145 | name = filepath.Join(dir, name) 146 | 147 | // Check if the file exists first, and if it does, panic. 148 | if _, err = os.Stat(name); err == nil { 149 | panic(fmt.Sprintf("file %s already exists, not doing anything", name)) 150 | } 151 | 152 | f, err := os.Create(name) 153 | if err != nil { 154 | panic(err) 155 | } 156 | 157 | err = tmpl.Execute(f, data) 158 | if err != nil { 159 | panic(err) 160 | } 161 | 162 | f.Close() 163 | } 164 | 165 | func main() { 166 | slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ 167 | Level: slog.LevelDebug, 168 | }))) 169 | 170 | if len(os.Args) < 3 { //nolint:gomnd 171 | slog.Error("usage: codegen ") 172 | os.Exit(1) 173 | } 174 | 175 | var data OptionData 176 | 177 | slog.Info("reading option data file", "file", os.Args[1]) 178 | optionDataFile, err := os.Open(os.Args[1]) 179 | if err != nil { 180 | slog.Error("failed to open option data file", "error", err) 181 | os.Exit(1) 182 | } 183 | defer optionDataFile.Close() 184 | 185 | slog.Info("decoding option data") 186 | err = json.NewDecoder(optionDataFile).Decode(&data) 187 | if err != nil { 188 | slog.Error("failed to decode option data", "error", err) 189 | os.Exit(1) 190 | } 191 | 192 | data.Generate() 193 | 194 | createTemplateFile(os.Args[2], "optiondata/optiondata.gen.go", optionDataTmpl, data) 195 | createTemplateFile(os.Args[2], "constants.gen.go", constantsTmpl, data) 196 | createTemplateFile(os.Args[2], "builder.gen.go", builderTmpl, data) 197 | createTemplateFile(os.Args[2], "builder.gen_test.go", builderTestTmpl, data) 198 | createTemplateFile(os.Args[2], "command_json.gen.go", commandJSONTmpl, data) 199 | } 200 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # :handshake: Contributing 3 | 4 | This document outlines some of the guidelines that we try and adhere to while 5 | working on this project. 6 | 7 | > :point_right: **Note**: before participating in the community, please read our 8 | > [Code of Conduct][coc]. 9 | > By interacting with this repository, organization, or community you agree to 10 | > abide by our Code of Conduct. 11 | > 12 | > Additionally, if you contribute **any source code** to this repository, you 13 | > agree to the terms of the [Developer Certificate of Origin][dco]. This helps 14 | > ensure that contributions aren't in violation of 3rd party license terms. 15 | 16 | ## :lady_beetle: Issue submission 17 | 18 | When [submitting an issue][issues] or bug report, 19 | please follow these guidelines: 20 | 21 | * Provide as much information as possible (logs, metrics, screenshots, 22 | runtime environment, etc). 23 | * Ensure that you are running on the latest stable version (tagged), or 24 | when using `master`, provide the specific commit being used. 25 | * Provide the minimum needed viable source to replicate the problem. 26 | 27 | ## :bulb: Feature requests 28 | 29 | When [submitting a feature request][issues], please 30 | follow these guidelines: 31 | 32 | * Does this feature benefit others? or just your usecase? If the latter, 33 | it will likely be declined, unless it has a more broad benefit to others. 34 | * Please include the pros and cons of the feature. 35 | * If possible, describe how the feature would work, and any diagrams/mock 36 | examples of what the feature would look like. 37 | 38 | ## :rocket: Pull requests 39 | 40 | To review what is currently being worked on, or looked into, feel free to head 41 | over to the [open pull requests][pull-requests] or [issues list][issues]. 42 | 43 | ## :raised_back_of_hand: Assistance with discussions 44 | 45 | * Take a look at the [open discussions][discussions], and if you feel like 46 | you'd like to help out other members of the community, it would be much 47 | appreciated! 48 | 49 | ## :pushpin: Guidelines 50 | 51 | ### :test_tube: Language agnostic 52 | 53 | Below are a few guidelines if you would like to contribute: 54 | 55 | * If the feature is large or the bugfix has potential breaking changes, 56 | please open an issue first to ensure the changes go down the best path. 57 | * If possible, break the changes into smaller PRs. Pull requests should be 58 | focused on a specific feature/fix. 59 | * Pull requests will only be accepted with sufficient documentation 60 | describing the new functionality/fixes. 61 | * Keep the code simple where possible. Code that is smaller/more compact 62 | does not mean better. Don't do magic behind the scenes. 63 | * Use the same formatting/styling/structure as existing code. 64 | * Follow idioms and community-best-practices of the related language, 65 | unless the previous above guidelines override what the community 66 | recommends. 67 | * Always test your changes, both the features/fixes being implemented, but 68 | also in the standard way that a user would use the project (not just 69 | your configuration that fixes your issue). 70 | * Only use 3rd party libraries when necessary. If only a small portion of 71 | the library is needed, simply rewrite it within the library to prevent 72 | useless imports. 73 | 74 | ### :hamster: Golang 75 | 76 | * See [golang/go/wiki/CodeReviewComments](https://github.com/golang/go/wiki/CodeReviewComments) 77 | * This project uses [golangci-lint](https://golangci-lint.run/) for 78 | Go-related files. This should be available for any editor that supports 79 | `gopls`, however you can also run it locally with `golangci-lint run` 80 | after installing it. 81 | 82 | 83 | 84 | 85 | 86 | 87 | ### :penguin: Bash/Posix-shell 88 | 89 | * This project uses [shellcheck](https://github.com/koalaman/shellcheck) 90 | for linting `bash` and `sh` scripts. It helps write proper scripts, and 91 | should help catch any potential bugs/issues. This is available in VSCode 92 | [here](https://marketplace.visualstudio.com/items?itemName=timonwong.shellcheck). 93 | * `shfmt` should be used if possible, to auto-format shell scripts. The 94 | flags that should generally be used are: `shfmt -s -bn -ci -sr`. This is 95 | available in VSCode [here](https://marketplace.visualstudio.com/items?itemName=foxundermoon.shell-format). 96 | 97 | 98 | 99 | 100 | 101 | ## :clipboard: References 102 | 103 | * [Open Source: How to Contribute](https://opensource.guide/how-to-contribute/) 104 | * [About pull requests](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests) 105 | * [GitHub Docs](https://docs.github.com/) 106 | 107 | ## :speech_balloon: What to do next? 108 | 109 | * :old_key: Find a vulnerability? Check out our [Security and Disclosure][security] policy. 110 | * :link: Repository [License][license]. 111 | * [Support][support] 112 | * [Code of Conduct][coc]. 113 | 114 | 115 | [coc]: https://github.com/lrstanley/go-ytdlp/blob/master/.github/CODE_OF_CONDUCT.md 116 | [dco]: https://developercertificate.org/ 117 | [discussions]: https://github.com/lrstanley/go-ytdlp/discussions 118 | [issues]: https://github.com/lrstanley/go-ytdlp/issues/new/choose 119 | [license]: https://github.com/lrstanley/go-ytdlp/blob/master/LICENSE 120 | [pull-requests]: https://github.com/lrstanley/go-ytdlp/pulls?q=is%3Aopen+is%3Apr 121 | [security]: https://github.com/lrstanley/go-ytdlp/security/policy 122 | [support]: https://github.com/lrstanley/go-ytdlp/blob/master/.github/SUPPORT.md 123 | -------------------------------------------------------------------------------- /_examples/go.sum: -------------------------------------------------------------------------------- 1 | github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= 2 | github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= 6 | github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= 7 | github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= 8 | github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= 9 | github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= 10 | github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= 11 | github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= 12 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 13 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 14 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 15 | github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= 16 | github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= 17 | github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= 18 | github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 19 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 20 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 21 | github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= 22 | github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 23 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 24 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 26 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 27 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= 28 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 29 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 30 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 31 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 32 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 33 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 34 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 35 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 36 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 37 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 38 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= 42 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= 43 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 44 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 45 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 46 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 47 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 48 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 49 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 50 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 51 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 52 | github.com/samber/slog-http v1.7.0 h1:sFrwkdw3Nrtcqq6WLkFL0K0Drlh76TPRvo0d8epF2a4= 53 | github.com/samber/slog-http v1.7.0/go.mod h1:PAcQQrYFo5KM7Qbk50gNNwKEAMGCyfsw6GN5dI0iv9g= 54 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 55 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 56 | github.com/ulikunitz/xz v0.5.13 h1:ar98gWrjf4H1ev05fYP/o29PDZw9DrI3niHtnEqyuXA= 57 | github.com/ulikunitz/xz v0.5.13/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 58 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 59 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 60 | go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= 61 | go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= 62 | go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= 63 | go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= 64 | golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= 65 | golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 66 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 67 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 68 | golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 69 | golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 70 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 71 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 72 | golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 73 | golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 74 | golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 75 | golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 76 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 77 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 78 | -------------------------------------------------------------------------------- /_examples/bubble-dl/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "net/url" 11 | "os" 12 | "strings" 13 | "time" 14 | 15 | "github.com/charmbracelet/bubbles/progress" 16 | "github.com/charmbracelet/bubbles/spinner" 17 | tea "github.com/charmbracelet/bubbletea" 18 | "github.com/charmbracelet/lipgloss" 19 | "github.com/dustin/go-humanize" 20 | "github.com/lrstanley/go-ytdlp" 21 | ) 22 | 23 | const slowDownload = true 24 | 25 | var core *tea.Program 26 | 27 | var defaultDownloads = []string{ 28 | "https://cdn.liam.sh/github/go-ytdlp/sample-1.mp4", 29 | "https://cdn.liam.sh/github/go-ytdlp/sample-2.mp4", 30 | "https://cdn.liam.sh/github/go-ytdlp/sample-3.mp4", 31 | "https://cdn.liam.sh/github/go-ytdlp/sample-4.mpg", 32 | } 33 | 34 | type model struct { 35 | urls []string 36 | width int 37 | height int 38 | spinner spinner.Model 39 | progress progress.Model 40 | done bool 41 | 42 | lastProgress ytdlp.ProgressUpdate 43 | } 44 | 45 | var ( 46 | fileStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("211")) 47 | titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("93")) 48 | doneStyle = lipgloss.NewStyle().Margin(1, 2) 49 | errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).SetString("✗") 50 | successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).SetString("✓") 51 | etaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) 52 | sizeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) 53 | ) 54 | 55 | func newModel() model { 56 | m := model{ 57 | spinner: spinner.New(), 58 | progress: progress.New( 59 | progress.WithDefaultGradient(), 60 | progress.WithWidth(40), 61 | ), 62 | } 63 | 64 | if len(os.Args[1:]) > 0 { 65 | m.urls = os.Args[1:] 66 | } else { 67 | m.urls = defaultDownloads 68 | } 69 | 70 | for _, uri := range m.urls { 71 | _, err := url.Parse(uri) 72 | if err != nil { 73 | fmt.Printf("%s unvalid URL specified %q: %s\n", errorStyle, uri, err) //nolint:forbidigo 74 | os.Exit(1) 75 | } 76 | } 77 | 78 | m.spinner.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63")) 79 | 80 | return m 81 | } 82 | 83 | type MsgToolsVerified struct { 84 | Resolved []*ytdlp.ResolvedInstall 85 | Error error 86 | } 87 | 88 | func (m model) Init() tea.Cmd { 89 | return tea.Batch( 90 | // If yt-dlp/ffmpeg/ffprobe isn't installed yet, download and cache the binaries for further use. 91 | // Note that the download/installation of ffmpeg/ffprobe is only supported on a handful of platforms, 92 | // and so it is still recommended to install ffmpeg/ffprobe via other means. 93 | func() tea.Msg { 94 | resolved, err := ytdlp.InstallAll(context.TODO()) 95 | if err != nil { 96 | return MsgToolsVerified{Resolved: resolved, Error: err} 97 | } 98 | return MsgToolsVerified{Resolved: resolved} 99 | }, 100 | m.spinner.Tick, 101 | ) 102 | } 103 | 104 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 105 | switch msg := msg.(type) { 106 | case tea.WindowSizeMsg: 107 | m.width, m.height = msg.Width, msg.Height 108 | case tea.KeyMsg: 109 | switch msg.String() { 110 | case "ctrl+c", "esc", "q": 111 | return m, tea.Quit 112 | } 113 | case MsgToolsVerified: 114 | if msg.Error != nil { 115 | return m, tea.Sequence( 116 | tea.Printf("%s error installing/verifying tools: %s", errorStyle, msg.Error), 117 | tea.Quit, 118 | ) 119 | } 120 | 121 | var cmds []tea.Cmd 122 | 123 | for _, r := range msg.Resolved { 124 | cmds = append(cmds, tea.Printf( 125 | "%s installed/verified tool %s (version: %s)", 126 | successStyle, 127 | r.Executable, 128 | r.Version, 129 | )) 130 | } 131 | 132 | return m, tea.Sequence(append(cmds, m.initiateDownload)...) 133 | case MsgProgress: 134 | m.lastProgress = msg.Progress 135 | cmds := []tea.Cmd{m.progress.SetPercent(msg.Progress.Percent() / 100)} 136 | 137 | if m.lastProgress.Status == ytdlp.ProgressStatusFinished { 138 | cmds = append(cmds, tea.Printf( 139 | "%s downloaded %s (%s)", 140 | successStyle, 141 | fileStyle.Render(*m.lastProgress.Info.URL), 142 | titleStyle.Render(*m.lastProgress.Info.Filename), 143 | )) 144 | } 145 | if m.lastProgress.Status == ytdlp.ProgressStatusError { 146 | cmds = append(cmds, tea.Printf( 147 | "%s error downloading: %s", 148 | errorStyle, 149 | *m.lastProgress.Info.URL, 150 | )) 151 | } 152 | return m, tea.Sequence(cmds...) 153 | case MsgFinished: 154 | m.done = true 155 | if msg.Err != nil { 156 | return m, tea.Sequence( 157 | tea.Printf("%s error downloading urls: %s", errorStyle, msg.Err), 158 | tea.Quit, 159 | ) 160 | } 161 | return m, tea.Quit 162 | case spinner.TickMsg: 163 | var cmd tea.Cmd 164 | m.spinner, cmd = m.spinner.Update(msg) 165 | return m, cmd 166 | case progress.FrameMsg: 167 | newModel, cmd := m.progress.Update(msg) 168 | if newModel, ok := newModel.(progress.Model); ok { 169 | m.progress = newModel 170 | } 171 | return m, cmd 172 | } 173 | return m, nil 174 | } 175 | 176 | type MsgProgress struct { 177 | Progress ytdlp.ProgressUpdate 178 | } 179 | 180 | type MsgFinished struct { 181 | Result *ytdlp.Result 182 | Err error 183 | } 184 | 185 | func (m model) initiateDownload() tea.Msg { 186 | dl := ytdlp.New(). 187 | FormatSort("res,ext:mp4:m4a"). 188 | RecodeVideo("mp4"). 189 | ForceOverwrites(). 190 | ProgressFunc(100*time.Millisecond, func(prog ytdlp.ProgressUpdate) { 191 | core.Send(MsgProgress{Progress: prog}) 192 | }). 193 | Output("%(extractor)s - %(title)s.%(ext)s") 194 | 195 | if slowDownload { 196 | dl = dl.LimitRate("2M") 197 | } 198 | 199 | result, err := dl.Run(context.TODO(), m.urls...) 200 | 201 | return MsgFinished{Result: result, Err: err} 202 | } 203 | 204 | func (m model) View() string { 205 | // " [eta: ] [size: ]" 206 | 207 | if m.lastProgress.Status == "" { 208 | return doneStyle.Render(m.spinner.View() + " fetching url information...\n") 209 | } 210 | 211 | if m.done { 212 | return doneStyle.Render(fmt.Sprintf("downloaded %d urls.\n", len(m.urls))) 213 | } 214 | 215 | spin := m.spinner.View() 216 | status := string(m.lastProgress.Status) 217 | prog := m.progress.View() 218 | eta := m.lastProgress.ETA().Round(time.Second).String() 219 | eta = "[eta: " + etaStyle.MarginLeft(max(0, 4-len(eta))).Render(eta) + "]" 220 | size := "[size: " + sizeStyle.Render(humanize.Bytes(uint64(m.lastProgress.TotalBytes))) + "]" 221 | 222 | cellsAvail := max(0, m.width-lipgloss.Width(spin+" "+status+" "+prog+" "+eta+" "+size)) 223 | 224 | file := fileStyle.MaxWidth(cellsAvail).Render(m.lastProgress.Filename) 225 | 226 | cellsRemaining := max(0, cellsAvail-lipgloss.Width(file)) 227 | gap := strings.Repeat(" ", cellsRemaining) 228 | 229 | return spin + " " + status + " " + file + gap + prog + " " + eta + " " + size 230 | } 231 | 232 | func main() { 233 | core = tea.NewProgram(newModel()) 234 | _, err := core.Run() 235 | if err != nil { 236 | fmt.Printf("error running program: %v\n", err) //nolint:forbidigo 237 | os.Exit(1) 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /command_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | //nolint:forbidigo 6 | package ytdlp 7 | 8 | import ( 9 | "context" 10 | "fmt" 11 | "io/fs" 12 | "os" 13 | "path/filepath" 14 | "slices" 15 | "testing" 16 | "time" 17 | ) 18 | 19 | func TestMain(m *testing.M) { 20 | os.Setenv("YTDLP_DEBUG", "true") 21 | MustInstallAll(context.TODO()) 22 | os.Exit(m.Run()) 23 | } 24 | 25 | type testSampleFile struct { 26 | url string 27 | name string 28 | ext string 29 | extractor string 30 | } 31 | 32 | var sampleFiles = []testSampleFile{ 33 | {url: "https://cdn.liam.sh/github/go-ytdlp/sample-1.mp4", name: "sample-1", ext: "mp4", extractor: "generic"}, 34 | {url: "https://cdn.liam.sh/github/go-ytdlp/sample-2.mp4", name: "sample-2", ext: "mp4", extractor: "generic"}, 35 | {url: "https://cdn.liam.sh/github/go-ytdlp/sample-3.mp4", name: "sample-3", ext: "mp4", extractor: "generic"}, 36 | {url: "https://cdn.liam.sh/github/go-ytdlp/sample-4.mpg", name: "sample-4", ext: "mpg", extractor: "generic"}, 37 | } 38 | 39 | func TestCommand_Simple(t *testing.T) { 40 | t.Parallel() 41 | 42 | dir := t.TempDir() 43 | 44 | var urls []string 45 | 46 | for _, f := range sampleFiles { 47 | urls = append(urls, f.url) 48 | } 49 | 50 | progressUpdates := map[string]ProgressUpdate{} 51 | 52 | res, err := New(). 53 | NoUpdate(). 54 | Verbose(). 55 | PrintJSON(). 56 | NoProgress(). 57 | NoOverwrites(). 58 | Output(filepath.Join(dir, "%(extractor)s - %(title)s.%(ext)s")). 59 | ProgressFunc(100*time.Millisecond, func(prog ProgressUpdate) { 60 | progressUpdates[prog.Filename] = prog 61 | }). 62 | Run(context.Background(), urls...) 63 | if err != nil { 64 | t.Fatal(err) 65 | } 66 | 67 | if res == nil { 68 | t.Fatal("res is nil") 69 | } 70 | 71 | if res.ExitCode != 0 { 72 | t.Fatalf("expected exit code 0, got %d", res.ExitCode) 73 | } 74 | 75 | if !slices.Contains(res.Args, "--verbose") { 76 | t.Fatal("expected --verbose flag to be set") 77 | } 78 | 79 | var hasJSON bool 80 | for _, l := range res.OutputLogs { 81 | if l.JSON != nil { 82 | hasJSON = true 83 | break 84 | } 85 | } 86 | 87 | if !hasJSON { 88 | t.Fatal("expected at least one log line to be valid JSON due to one of --print-json/--dump-json/--print '%()j'") 89 | } 90 | 91 | for _, f := range sampleFiles { 92 | t.Run(f.name, func(t *testing.T) { 93 | var stat fs.FileInfo 94 | 95 | fn := filepath.Join(dir, fmt.Sprintf("%s - %s.%s", f.extractor, f.name, f.ext)) 96 | 97 | stat, err = os.Stat(fn) 98 | if err != nil { 99 | t.Fatal(err) 100 | } 101 | 102 | if stat.Size() == 0 { 103 | t.Fatal("file is empty") 104 | } 105 | 106 | prog, ok := progressUpdates[fn] 107 | if !ok { 108 | t.Fatalf("expected progress updates for %s", fn) 109 | } 110 | 111 | if prog.Finished.IsZero() || prog.Started.IsZero() { 112 | t.Fatal("expected progress start and finish times to be set") 113 | } 114 | 115 | if prog.TotalBytes == 0 { 116 | t.Fatal("expected progress total bytes to be set") 117 | } 118 | if prog.DownloadedBytes == 0 { 119 | t.Fatal("expected progress downloaded bytes to be set") 120 | } 121 | 122 | if prog.Percent() < 100.0 { 123 | t.Fatalf("expected progress to be 100%%, got %.2f%%", prog.Percent()) 124 | } 125 | 126 | if prog.Info.URL == nil { 127 | t.Fatal("expected progress info URL to be set") 128 | } 129 | }) 130 | } 131 | } 132 | 133 | func TestCommand_Version(t *testing.T) { 134 | t.Parallel() 135 | 136 | res, err := New().NoUpdate().Version(context.Background()) 137 | if err != nil { 138 | t.Fatal(err) 139 | } 140 | 141 | if res == nil { 142 | t.Fatal("res is nil") 143 | } 144 | 145 | if res.ExitCode != 0 { 146 | t.Fatalf("expected exit code 0, got %d", res.ExitCode) 147 | } 148 | 149 | _, err = time.Parse("2006.01.02", res.Stdout) 150 | if err != nil { 151 | t.Fatalf("failed to parse version: %v", err) 152 | } 153 | } 154 | 155 | func TestCommand_Unset(t *testing.T) { 156 | t.Parallel() 157 | 158 | builder := New().NoUpdate().Progress().NoProgress().Output("test.mp4") 159 | 160 | cmd := builder.BuildCommand(context.TODO(), sampleFiles[0].url) 161 | 162 | // Make sure --no-progress is set. 163 | if !slices.Contains(cmd.Args, "--no-progress") { 164 | t.Fatal("expected --no-progress flag to be set") 165 | } 166 | 167 | _ = builder.UnsetProgress() 168 | 169 | cmd = builder.BuildCommand(context.TODO(), sampleFiles[0].url) 170 | 171 | // Make sure --no-progress is not set. 172 | if slices.Contains(cmd.Args, "--no-progress") { 173 | t.Fatal("expected --no-progress flag to not be set") 174 | } 175 | 176 | // Progress and NoProgress should conflict, so arg length should be 5 (no-update, executable, output, output value, and url). 177 | if len(cmd.Args) != 5 { 178 | t.Fatalf("expected arg length to be 4, got %d: %#v", len(cmd.Args), cmd.Args) 179 | } 180 | } 181 | 182 | func TestCommand_Clone(t *testing.T) { 183 | t.Parallel() 184 | 185 | builder1 := New().NoUpdate().NoProgress().Output("test.mp4") 186 | 187 | builder2 := builder1.Clone() 188 | 189 | cmd := builder2.BuildCommand(context.TODO(), sampleFiles[0].url) 190 | 191 | // Make sure --no-progress is set. 192 | if !slices.Contains(cmd.Args, "--no-progress") { 193 | t.Fatal("expected --no-progress flag to be set") 194 | } 195 | } 196 | 197 | func TestCommand_SetExecutable(t *testing.T) { 198 | t.Parallel() 199 | 200 | cmd := New().NoUpdate().SetExecutable("/usr/bin/test").BuildCommand(context.Background(), sampleFiles[0].url) 201 | 202 | if cmd.Path != "/usr/bin/test" { 203 | t.Fatalf("expected executable to be /usr/bin/test, got %s", cmd.Path) 204 | } 205 | } 206 | 207 | func TestCommand_SetWorkDir(t *testing.T) { 208 | t.Parallel() 209 | 210 | cmd := New().NoUpdate().SetWorkDir("/tmp").BuildCommand(context.Background(), sampleFiles[0].url) 211 | 212 | if cmd.Dir != "/tmp" { 213 | t.Fatalf("expected workdir to be /tmp, got %s", cmd.Dir) 214 | } 215 | } 216 | 217 | func TestCommand_SetEnvVar(t *testing.T) { 218 | t.Parallel() 219 | 220 | cmd := New().NoUpdate().SetEnvVar("TEST", "1").BuildCommand(context.Background(), sampleFiles[0].url) 221 | 222 | if !slices.Contains(cmd.Env, "TEST=1") { 223 | t.Fatalf("expected env var to be TEST=1, got %v", cmd.Env) 224 | } 225 | } 226 | 227 | func TestCommand_SetFlagConfig_DuplicateFlags(t *testing.T) { 228 | t.Parallel() 229 | 230 | flagConfig := &FlagConfig{} 231 | flagConfig.General.IgnoreErrors = ptr(true) 232 | flagConfig.General.AbortOnError = ptr(true) 233 | 234 | builder := New().NoUpdate().SetFlagConfig(flagConfig) 235 | 236 | err := builder.flagConfig.General.Validate() 237 | if err == nil { 238 | t.Fatal("expected validation error, got nil") 239 | } 240 | 241 | if _, ok := IsMultipleJSONParsingFlagsError(err); !ok { 242 | t.Fatalf("expected validation error to be a multiple JSON parsing flags error, got %v", err) 243 | } 244 | } 245 | 246 | func TestCommand_JSONClone(t *testing.T) { 247 | t.Parallel() 248 | 249 | builder := New().NoUpdate().IgnoreErrors().Output("test.mp4") 250 | 251 | cloned := builder.GetFlagConfig().Clone() 252 | 253 | if cloned.General.IgnoreErrors == nil { 254 | t.Fatal("expected ignore errors to be set") 255 | } 256 | 257 | if v := cloned.Filesystem.Output; v == nil { 258 | t.Fatal("expected output to be set") 259 | } 260 | 261 | if *cloned.Filesystem.Output != "test.mp4" { 262 | t.Fatalf("expected output to be %q, got %q", "test.mp4", *cloned.Filesystem.Output) 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /progress.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package ytdlp 6 | 7 | import ( 8 | "encoding/json" 9 | "fmt" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | "time" 14 | ) 15 | 16 | var progressPrefix = []byte("progress:") 17 | 18 | const progressFormat = "%()j" 19 | 20 | type progressData struct { 21 | Info *ExtractedInfo `json:"info"` 22 | Progress struct { 23 | Status ProgressStatus `json:"status"` 24 | TotalBytes int `json:"total_bytes,omitempty"` 25 | TotalBytesEstimate float64 `json:"total_bytes_estimate,omitempty"` 26 | DownloadedBytes int `json:"downloaded_bytes"` 27 | Filename string `json:"filename,omitempty"` 28 | TmpFilename string `json:"tmpfilename,omitempty"` 29 | FragmentIndex int `json:"fragment_index,omitempty"` 30 | FragmentCount int `json:"fragment_count,omitempty"` 31 | // There are technically other fields, but these are the important ones. 32 | } `json:"progress"` 33 | AutoNumber int `json:"autonumber,omitempty"` 34 | VideoAutoNumber int `json:"video_autonumber,omitempty"` 35 | } 36 | 37 | type progressHandler struct { 38 | fn ProgressCallbackFunc 39 | 40 | mu sync.Mutex 41 | started map[string]time.Time // Used to track multiple independent downloads. 42 | finished map[string]time.Time // Used to track multiple independent downloads. 43 | } 44 | 45 | func newProgressHandler(fn ProgressCallbackFunc) *progressHandler { 46 | h := &progressHandler{ 47 | fn: fn, 48 | started: make(map[string]time.Time), 49 | finished: make(map[string]time.Time), 50 | } 51 | return h 52 | } 53 | 54 | func (h *progressHandler) parse(raw json.RawMessage) { 55 | data := &progressData{} 56 | 57 | err := json.Unmarshal(raw, data) 58 | if err != nil { 59 | return 60 | } 61 | 62 | cleanJSON(data) 63 | 64 | update := ProgressUpdate{ 65 | Info: data.Info, 66 | Status: data.Progress.Status, 67 | TotalBytes: data.Progress.TotalBytes, 68 | DownloadedBytes: data.Progress.DownloadedBytes, 69 | FragmentIndex: data.Progress.FragmentIndex, 70 | FragmentCount: data.Progress.FragmentCount, 71 | Filename: data.Progress.Filename, 72 | } 73 | 74 | if update.TotalBytes == 0 { 75 | update.TotalBytes = int(data.Progress.TotalBytesEstimate) 76 | } 77 | 78 | if update.Filename == "" { 79 | if data.Progress.TmpFilename != "" { 80 | update.Filename = data.Progress.TmpFilename 81 | } else if data.Info.Filename != nil && *data.Info.Filename != "" { 82 | update.Filename = *data.Info.Filename 83 | } 84 | } 85 | 86 | uuid := update.uuid() 87 | 88 | var ok bool 89 | 90 | h.mu.Lock() 91 | update.Started, ok = h.started[uuid] 92 | if !ok { 93 | update.Started = time.Now() 94 | h.started[uuid] = update.Started 95 | } 96 | 97 | update.Finished, ok = h.finished[uuid] 98 | if !ok && update.Status.IsCompletedType() { 99 | update.Finished = time.Now() 100 | h.finished[uuid] = update.Finished 101 | } 102 | h.mu.Unlock() 103 | 104 | h.fn(update) 105 | } 106 | 107 | // ProgressStatus is the status of the download progress. 108 | type ProgressStatus string 109 | 110 | func (s ProgressStatus) IsCompletedType() bool { 111 | return s == ProgressStatusError || s == ProgressStatusFinished 112 | } 113 | 114 | const ( 115 | ProgressStatusStarting ProgressStatus = "starting" 116 | ProgressStatusDownloading ProgressStatus = "downloading" 117 | ProgressStatusPostProcessing ProgressStatus = "post_processing" 118 | ProgressStatusError ProgressStatus = "error" 119 | ProgressStatusFinished ProgressStatus = "finished" 120 | ) 121 | 122 | // ProgressCallbackFunc is a callback function that is called when (if) we receive 123 | // progress updates from yt-dlp. 124 | type ProgressCallbackFunc func(update ProgressUpdate) 125 | 126 | // ProgressUpdate is a point-in-time snapshot of the download progress. 127 | type ProgressUpdate struct { 128 | Info *ExtractedInfo `json:"info"` 129 | 130 | // Status is the current status of the download. 131 | Status ProgressStatus `json:"status"` 132 | // TotalBytes is the total number of bytes in the download. If yt-dlp is unable 133 | // to determine the total bytes, this will be 0. 134 | TotalBytes int `json:"total_bytes"` 135 | // DownloadedBytes is the number of bytes that have been downloaded so far. 136 | DownloadedBytes int `json:"downloaded_bytes"` 137 | // FragmentIndex is the index of the current fragment being downloaded. 138 | FragmentIndex int `json:"fragment_index,omitempty"` 139 | // FragmentCount is the total number of fragments in the download. 140 | FragmentCount int `json:"fragment_count,omitempty"` 141 | 142 | // Filename is the filename of the video being downloaded, if available. Note that 143 | // this is not necessarily the same as the destination file, as post-processing 144 | // may merge multiple files into one. 145 | Filename string `json:"filename"` 146 | 147 | // Started is the time the download started. 148 | Started time.Time `json:"started"` 149 | // Finished is the time the download finished. If the download is still in progress, 150 | // this will be zero. You can validate with IsZero(). 151 | Finished time.Time `json:"finished,omitempty"` 152 | } 153 | 154 | func (p *ProgressUpdate) uuid() string { 155 | unique := []string{ 156 | p.Filename, 157 | p.Info.ID, 158 | } 159 | 160 | if p.Info.PlaylistID != nil { 161 | unique = append(unique, *p.Info.PlaylistID) 162 | } 163 | 164 | if p.Info.PlaylistIndex != nil { 165 | unique = append(unique, strconv.Itoa(*p.Info.PlaylistIndex)) 166 | } 167 | 168 | return strings.Join(unique, ":") 169 | } 170 | 171 | // Duration returns the duration of the download. If the download is still in progress, 172 | // it will return the time since the download started. 173 | func (p *ProgressUpdate) Duration() time.Duration { 174 | if p.Finished.IsZero() { 175 | return time.Since(p.Started) 176 | } 177 | return p.Finished.Sub(p.Started) 178 | } 179 | 180 | // ETA returns the estimated time until the download is complete. If the download is 181 | // complete, or hasn't started yet, it will return 0. 182 | func (p *ProgressUpdate) ETA() time.Duration { 183 | perc := p.Percent() 184 | if perc == 0 || perc == 100 { 185 | return 0 186 | } 187 | return time.Duration(float64(p.Duration().Nanoseconds()) / perc * (100 - perc)) 188 | } 189 | 190 | // Percent returns the percentage of the download that has been completed. If yt-dlp 191 | // is unable to determine the total bytes, it will return 0. 192 | func (p *ProgressUpdate) Percent() float64 { 193 | if p.Status.IsCompletedType() { 194 | return 100 195 | } 196 | if p.TotalBytes == 0 { 197 | return 0 198 | } 199 | return float64(p.DownloadedBytes) / float64(p.TotalBytes) * 100 200 | } 201 | 202 | // PercentString is like Percent, but returns a string representation of the percentage. 203 | func (p *ProgressUpdate) PercentString() string { 204 | return fmt.Sprintf("%.2f%%", p.Percent()) 205 | } 206 | 207 | // ProgressFunc can be used to register a callback function that will be called when 208 | // yt-dlp sends progress updates. The callback function will be called with any information 209 | // that yt-dlp is able to provide, including sending separate updates for each file, playlist, 210 | // etc that may be downloaded. 211 | // - See [Command.UnsetProgressFunc], for unsetting the progress function. 212 | func (c *Command) ProgressFunc(frequency time.Duration, fn ProgressCallbackFunc) *Command { 213 | if frequency < 100*time.Millisecond { 214 | frequency = 100 * time.Millisecond 215 | } 216 | 217 | c.Progress(). 218 | ProgressDelta(frequency.Seconds()). 219 | ProgressTemplate(string(progressPrefix) + progressFormat). 220 | Newline() 221 | 222 | c.mu.Lock() 223 | c.progress = newProgressHandler(fn) 224 | c.mu.Unlock() 225 | 226 | return c 227 | } 228 | 229 | // UnsetProgressFunc can be used to unset the progress function that was previously set 230 | // with [Command.ProgressFunc]. 231 | func (c *Command) UnsetProgressFunc() *Command { 232 | c.mu.Lock() 233 | c.progress = nil 234 | c.mu.Unlock() 235 | 236 | return c 237 | } 238 | -------------------------------------------------------------------------------- /install_ytdlp.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package ytdlp 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | _ "embed" 11 | "errors" 12 | "fmt" 13 | "os" 14 | "os/exec" 15 | "path/filepath" 16 | "runtime" 17 | "strings" 18 | "sync" 19 | "sync/atomic" 20 | ) 21 | 22 | var ( 23 | //go:embed .github/ytdlp-public.key 24 | ytdlpPublicKey []byte // From: https://github.com/yt-dlp/yt-dlp/blob/master/public.key 25 | 26 | ytdlpResolveCache = atomic.Pointer[ResolvedInstall]{} // Should only be used by [Install]. 27 | ytdlpInstallLock sync.Mutex 28 | 29 | ytdlpBinConfigs = map[string]struct { 30 | src string 31 | dest []string 32 | }{ 33 | "darwin_amd64": {"yt-dlp_macos", []string{"yt-dlp-" + Version, "yt-dlp"}}, 34 | "darwin_arm64": {"yt-dlp_macos", []string{"yt-dlp-" + Version, "yt-dlp"}}, 35 | "linux_amd64": {"yt-dlp_linux", []string{"yt-dlp-" + Version, "yt-dlp"}}, 36 | "linux_arm64": {"yt-dlp_linux_aarch64", []string{"yt-dlp-" + Version, "yt-dlp"}}, 37 | "linux_armv7l": {"yt-dlp_linux_armv7l", []string{"yt-dlp-" + Version, "yt-dlp"}}, 38 | "linux_unknown": {"yt-dlp", []string{"yt-dlp-" + Version, "yt-dlp"}}, 39 | "windows_amd64": {"yt-dlp.exe", []string{"yt-dlp-" + Version + ".exe", "yt-dlp.exe"}}, 40 | } 41 | ) 42 | 43 | // ytdlpGetDownloadBinary returns the source and destination binary names for the 44 | // current runtime. If the current runtime is not supported, an error is 45 | // returned. dest will always be returned (it will be an assumption). 46 | func ytdlpGetDownloadBinary() (src string, dest []string, err error) { 47 | src = runtime.GOOS + "_" + runtime.GOARCH 48 | if binary, ok := ytdlpBinConfigs[src]; ok { 49 | return binary.src, binary.dest, nil 50 | } 51 | 52 | if runtime.GOOS == "linux" { 53 | return ytdlpBinConfigs["linux_unknown"].src, ytdlpBinConfigs["linux_unknown"].dest, nil 54 | } 55 | 56 | var supported []string 57 | for k := range ytdlpBinConfigs { 58 | supported = append(supported, k) 59 | } 60 | 61 | if runtime.GOOS == "windows" { 62 | dest = []string{"yt-dlp.exe"} 63 | } else { 64 | dest = []string{"yt-dlp"} 65 | } 66 | 67 | return "", dest, fmt.Errorf( 68 | "unsupported os/arch combo: %s/%s (supported: %s)", 69 | runtime.GOOS, 70 | runtime.GOARCH, 71 | strings.Join(supported, ", "), 72 | ) 73 | } 74 | 75 | // InstallOptions are configuration options for installing yt-dlp dynamically (when 76 | // it's not already installed). 77 | type InstallOptions struct { 78 | // DisableDownload is a simple toggle to never allow downloading, which would 79 | // be the same as never calling [Install] or [MustInstall] in the first place. 80 | DisableDownload bool 81 | 82 | // DisableChecksum disables checksum verification when downloading. 83 | DisableChecksum bool 84 | 85 | // DisableSystem is a simple toggle to never allow resolving from the system PATH. 86 | DisableSystem bool 87 | 88 | // AllowVersionMismatch allows mismatched versions to be used and installed. 89 | // This will only be used when the yt-dlp executable is resolved outside of 90 | // go-ytdlp's cache. 91 | // 92 | // AllowVersionMismatch is ignored if DisableDownload is true. 93 | AllowVersionMismatch bool 94 | 95 | // DownloadURL is the exact url to the binary location to download (and store). 96 | // Leave empty to use GitHub + auto-detected os/arch. 97 | DownloadURL string 98 | } 99 | 100 | func ytdlpGithubReleaseAsset(name string) string { 101 | return fmt.Sprintf("https://github.com/yt-dlp/yt-dlp/releases/download/%s/%s", Version, name) 102 | } 103 | 104 | // Install will check to see if yt-dlp is installed (if it's the right version), 105 | // and if not, will download it from GitHub. If yt-dlp is already installed, it will 106 | // check to see if the version matches (unless disabled with [AllowVersionMismatch]), 107 | // and if not, will download the same version that go-ytdlp (the version you are using) 108 | // was built with. 109 | // 110 | // Note: If [Install] is not called, go-ytdlp WILL NOT DOWNLOAD yt-dlp. Only use 111 | // this function if you want to ensure yt-dlp is installed, and are ok with it being 112 | // downloaded. 113 | func Install(ctx context.Context, opts *InstallOptions) (*ResolvedInstall, error) { 114 | if opts == nil { 115 | opts = &InstallOptions{} 116 | } 117 | 118 | if r := ytdlpResolveCache.Load(); r != nil { 119 | return r, nil 120 | } 121 | 122 | // Ensure only one install invocation is running at a time. 123 | ytdlpInstallLock.Lock() 124 | defer ytdlpInstallLock.Unlock() 125 | 126 | _, binaries, _ := ytdlpGetDownloadBinary() // don't check error yet. 127 | resolved, err := resolveExecutable(ctx, false, opts.DisableSystem, binaries) 128 | if err == nil { 129 | if resolved.Version == "" { 130 | err = ytdlpGetVersion(ctx, resolved) 131 | if err != nil { 132 | return nil, err 133 | } 134 | } 135 | 136 | if opts.AllowVersionMismatch { 137 | ytdlpResolveCache.Store(resolved) 138 | return resolved, nil 139 | } 140 | 141 | if resolved.Version == Version { 142 | ytdlpResolveCache.Store(resolved) 143 | return resolved, nil 144 | } 145 | 146 | // If we're not allowed to download, and the version doesn't match, return 147 | // an error. 148 | if opts.DisableDownload { 149 | return nil, fmt.Errorf("yt-dlp version mismatch: expected %s, got %s", Version, resolved.Version) 150 | } 151 | } 152 | 153 | if opts.DisableDownload { 154 | return nil, errors.New("yt-dlp executable not found, and downloading is disabled") 155 | } 156 | 157 | src, dest, err := ytdlpGetDownloadBinary() 158 | if err != nil { 159 | return nil, err 160 | } 161 | 162 | downloadURL := opts.DownloadURL 163 | 164 | if downloadURL == "" { 165 | downloadURL = ytdlpGithubReleaseAsset(src) 166 | } 167 | 168 | // Prepare cache directory. 169 | dir, err := createCacheDir(ctx) 170 | if err != nil { 171 | return nil, err 172 | } 173 | 174 | _, err = downloadFile(ctx, downloadURL, dir, filepath.Join(dir, dest[0]+".tmp"), 0o700) //nolint:gomnd 175 | if err != nil { 176 | return nil, err 177 | } 178 | 179 | if !opts.DisableChecksum { 180 | _, err = downloadFile( 181 | ctx, 182 | ytdlpGithubReleaseAsset("SHA2-256SUMS"), 183 | dir, 184 | filepath.Join(dir, "SHA2-256SUMS-"+Version), 185 | 0o700, 186 | ) //nolint:gomnd 187 | if err != nil { 188 | return nil, err 189 | } 190 | 191 | _, err = downloadFile( 192 | ctx, 193 | ytdlpGithubReleaseAsset("SHA2-256SUMS.sig"), 194 | dir, 195 | filepath.Join(dir, "SHA2-256SUMS-"+Version+".sig"), 196 | 0o700, 197 | ) //nolint:gomnd 198 | if err != nil { 199 | return nil, err 200 | } 201 | 202 | err = verifyFileChecksum( 203 | ctx, 204 | filepath.Join(dir, "SHA2-256SUMS-"+Version), 205 | filepath.Join(dir, "SHA2-256SUMS-"+Version+".sig"), 206 | filepath.Join(dir, dest[0]+".tmp"), 207 | src, 208 | ) 209 | if err != nil { 210 | return nil, err 211 | } 212 | } 213 | 214 | // Rename the file to the correct name. 215 | err = os.Rename(filepath.Join(dir, dest[0]+".tmp"), filepath.Join(dir, dest[0])) 216 | if err != nil { 217 | return nil, fmt.Errorf("unable to rename yt-dlp executable: %w", err) 218 | } 219 | 220 | // re-resolve now that we've downloaded the binary, and validated things. 221 | resolved, err = resolveExecutable(ctx, true, opts.DisableSystem, binaries) 222 | if err != nil { 223 | return nil, err 224 | } 225 | 226 | if resolved.Version == "" { 227 | err = ytdlpGetVersion(ctx, resolved) 228 | if err != nil { 229 | return nil, err 230 | } 231 | } 232 | 233 | ytdlpResolveCache.Store(resolved) 234 | return resolved, nil 235 | } 236 | 237 | // MustInstall is the same as [Install], but will panic if an error occurs (essentially 238 | // ensuring yt-dlp is installed, before continuing), and doesn't return any results. 239 | func MustInstall(ctx context.Context, opts *InstallOptions) { 240 | _, err := Install(ctx, opts) 241 | if err != nil { 242 | panic(err) 243 | } 244 | } 245 | 246 | // ytdlpGetVersion sets the version of the resolved executable. 247 | func ytdlpGetVersion(ctx context.Context, r *ResolvedInstall) error { 248 | var stdout bytes.Buffer 249 | 250 | cmd := exec.Command(r.Executable, "--version") //nolint:gosec 251 | cmd.Stdout = &stdout 252 | applySyscall(cmd, false) 253 | 254 | if err := cmd.Run(); err != nil { 255 | return fmt.Errorf("unable to run yt-dlp to verify version: %w", err) 256 | } 257 | 258 | r.Version = strings.TrimSpace(stdout.String()) 259 | debug(ctx, "yt-dlp version", "version", r.Version) 260 | return nil 261 | } 262 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | # THIS FILE IS GENERATED! DO NOT EDIT! Maintained by Terraform. 2 | # 3 | # golangci-lint: https://golangci-lint.run/ 4 | # false-positives: https://golangci-lint.run/usage/false-positives/ 5 | # actual source: https://github.com/lrstanley/.github/blob/master/terraform/github-common-files/templates/.golangci.yml 6 | # modified variant of: https://gist.github.com/maratori/47a4d00457a92aa426dbd48a18776322 7 | 8 | version: "2" 9 | 10 | formatters: 11 | enable: [gofumpt] 12 | 13 | issues: 14 | max-issues-per-linter: 0 15 | max-same-issues: 50 16 | 17 | severity: 18 | default: error 19 | rules: 20 | - linters: 21 | - errcheck 22 | - gocritic 23 | severity: warning 24 | 25 | linters: 26 | default: none 27 | enable: 28 | - asasalint # checks for pass []any as any in variadic func(...any) 29 | - asciicheck # checks that your code does not contain non-ASCII identifiers 30 | - bidichk # checks for dangerous unicode character sequences 31 | - bodyclose # checks whether HTTP response body is closed successfully 32 | - canonicalheader # checks whether net/http.Header uses canonical header 33 | - copyloopvar # detects places where loop variables are copied (Go 1.22+) 34 | - depguard # checks if package imports are in a list of acceptable packages 35 | - dupl # tool for code clone detection 36 | - durationcheck # checks for two durations multiplied together 37 | - errcheck # checking for unchecked errors, these unchecked errors can be critical bugs in some cases 38 | - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error 39 | - errorlint # finds code that will cause problems with the error wrapping scheme introduced in Go 1.13 40 | - exhaustive # checks exhaustiveness of enum switch statements 41 | - exptostd # detects functions from golang.org/x/exp/ that can be replaced by std functions 42 | - fatcontext # detects nested contexts in loops 43 | - forbidigo # forbids identifiers 44 | - funlen # tool for detection of long functions 45 | - gocheckcompilerdirectives # validates go compiler directive comments (//go:) 46 | - gochecknoinits # checks that no init functions are present in Go code 47 | - gochecksumtype # checks exhaustiveness on Go "sum types" 48 | - gocognit # computes and checks the cognitive complexity of functions 49 | - goconst # finds repeated strings that could be replaced by a constant 50 | - gocritic # provides diagnostics that check for bugs, performance and style issues 51 | - godot # checks if comments end in a period 52 | - gomoddirectives # manages the use of 'replace', 'retract', and 'excludes' directives in go.mod 53 | - goprintffuncname # checks that printf-like functions are named with f at the end 54 | - gosec # inspects source code for security problems 55 | - govet # reports suspicious constructs, such as Printf calls whose arguments do not align with the format string 56 | - iface # checks the incorrect use of interfaces, helping developers avoid interface pollution 57 | - ineffassign # detects when assignments to existing variables are not used 58 | - intrange # finds places where for loops could make use of an integer range 59 | - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) 60 | - makezero # finds slice declarations with non-zero initial length 61 | - mirror # reports wrong mirror patterns of bytes/strings usage 62 | - misspell # [useless] finds commonly misspelled English words in comments 63 | - musttag # enforces field tags in (un)marshaled structs 64 | - nakedret # finds naked returns in functions greater than a specified function length 65 | - nestif # reports deeply nested if statements 66 | - nilerr # finds the code that returns nil even if it checks that the error is not nil 67 | - nilnesserr # reports that it checks for err != nil, but it returns a different nil value error (powered by nilness and nilerr) 68 | - nilnil # checks that there is no simultaneous return of nil error and an invalid value 69 | - noctx # finds sending http request without context.Context 70 | - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL 71 | - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative 72 | - predeclared # finds code that shadows one of Go's predeclared identifiers 73 | - promlinter # checks Prometheus metrics naming via promlint 74 | - reassign # checks that package variables are not reassigned 75 | - recvcheck # checks for receiver type consistency 76 | - revive # fast, configurable, extensible, flexible, and beautiful linter for Go, drop-in replacement of golint 77 | - rowserrcheck # checks whether Err of rows is checked successfully 78 | - sloglint # ensure consistent code style when using log/slog 79 | - sqlclosecheck # checks that sql.Rows and sql.Stmt are closed 80 | - staticcheck # is a go vet on steroids, applying a ton of static analysis checks 81 | - testableexamples # checks if examples are testable (have an expected output) 82 | - testifylint # checks usage of github.com/stretchr/testify 83 | - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes 84 | - unconvert # removes unnecessary type conversions 85 | - unparam # reports unused function parameters 86 | - unused # checks for unused constants, variables, functions and types 87 | - usestdlibvars # detects the possibility to use variables/constants from the Go standard library 88 | - usetesting # reports uses of functions with replacement inside the testing package 89 | - wastedassign # finds wasted assignment statements 90 | - whitespace # detects leading and trailing whitespace 91 | 92 | settings: 93 | gocognit: 94 | min-complexity: 40 95 | errcheck: 96 | check-type-assertions: true 97 | funlen: 98 | lines: 150 99 | statements: 75 100 | ignore-comments: true 101 | gocritic: 102 | disabled-checks: 103 | - whyNoLint 104 | - hugeParam 105 | - ifElseChain 106 | - singleCaseSwitch 107 | enabled-tags: 108 | - diagnostic 109 | - opinionated 110 | - performance 111 | - style 112 | settings: 113 | captLocal: 114 | paramsOnly: false 115 | underef: 116 | skipRecvDeref: false 117 | rangeValCopy: 118 | sizeThreshold: 512 119 | depguard: 120 | rules: 121 | "deprecated": 122 | files: ["$all"] 123 | deny: 124 | - pkg: github.com/golang/protobuf 125 | desc: Use google.golang.org/protobuf instead, see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules 126 | - pkg: github.com/satori/go.uuid 127 | desc: Use github.com/google/uuid instead, satori's package is not maintained 128 | - pkg: github.com/gofrs/uuid$ 129 | desc: Use github.com/gofrs/uuid/v5 or later, it was not a go module before v5 130 | - pkg: github.com/lrstanley/clix$ 131 | desc: Use github.com/lrstanley/clix/v2 instead 132 | - pkg: github.com/lrstanley/chix$ 133 | desc: Use github.com/lrstanley/chix/v2 instead 134 | - pkg: log$ 135 | desc: Use log/slog instead, see https://go.dev/blog/slog 136 | "non-test files": 137 | files: ["!$test"] 138 | deny: 139 | - pkg: math/rand$ 140 | desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2 141 | "incorrect import": 142 | files: ["$test"] 143 | deny: 144 | - pkg: github.com/tj/assert$ 145 | desc: Use github.com/stretchr/testify/assert instead, see 146 | gochecksumtype: 147 | default-signifies-exhaustive: false 148 | exhaustive: 149 | check: 150 | - switch 151 | - map 152 | govet: 153 | disable: 154 | - fieldalignment 155 | enable-all: true 156 | settings: 157 | shadow: 158 | strict: true 159 | perfsprint: 160 | strconcat: false 161 | nakedret: 162 | max-func-lines: 0 163 | nestif: 164 | min-complexity: 10 165 | rowserrcheck: 166 | packages: 167 | - github.com/jmoiron/sqlx 168 | sloglint: 169 | no-global: default 170 | context: scope 171 | msg-style: lowercased 172 | static-msg: true 173 | forbidden-keys: 174 | - time 175 | - level 176 | - source 177 | staticcheck: 178 | checks: 179 | - all 180 | # Incorrect or missing package comment: https://staticcheck.dev/docs/checks/#ST1000 181 | - -ST1000 182 | # Use consistent method receiver names: https://staticcheck.dev/docs/checks/#ST1016 183 | - -ST1016 184 | # Omit embedded fields from selector expression: https://staticcheck.dev/docs/checks/#QF1008 185 | - -QF1008 186 | # duplicate struct tags -- used commonly for things like go-flags. 187 | - -SA5008 188 | usetesting: 189 | os-temp-dir: true 190 | exclusions: 191 | warn-unused: true 192 | generated: lax 193 | presets: 194 | - common-false-positives 195 | - std-error-handling 196 | paths: 197 | - ".*\\.gen\\.go$" 198 | - ".*\\.gen_test\\.go$" 199 | rules: 200 | - source: "TODO" 201 | linters: [godot] 202 | - text: "should have a package comment" 203 | linters: [revive] 204 | - text: 'exported \S+ \S+ should have comment( \(or a comment on this block\))? or be unexported' 205 | linters: [revive] 206 | - text: 'package comment should be of the form ".+"' 207 | source: "// ?(nolint|TODO)" 208 | linters: [revive] 209 | - text: 'comment on exported \S+ \S+ should be of the form ".+"' 210 | source: "// ?(nolint|TODO)" 211 | linters: [revive, staticcheck] 212 | - text: 'unexported-return: exported func \S+ returns unexported type \S+ .*' 213 | linters: [revive] 214 | - text: "var-declaration: should drop .* from declaration of .*; it is the zero value" 215 | linters: [revive] 216 | - text: ".*use ALL_CAPS in Go names.*" 217 | linters: [revive, staticcheck] 218 | - text: '.* always receives \S+' 219 | linters: [unparam] 220 | - path: _test\.go 221 | linters: 222 | - bodyclose 223 | - dupl 224 | - funlen 225 | - gocognit 226 | - goconst 227 | - gosec 228 | - noctx 229 | - wrapcheck 230 | -------------------------------------------------------------------------------- /cmd/codegen/constants.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package main 6 | 7 | // ignoredFlags are flags that are not intended to be used by the end-user and/or 8 | // don't make sense in a binding library scenario. 9 | // 10 | // - https://github.com/yt-dlp/yt-dlp/blob/master/README.md#developer-options 11 | var ignoredFlags = []string{ 12 | "--alias", // Higher-level abstraction, that go-ytdlp can do directly. 13 | "--allow-unplayable-formats", // Not intended to be used by the end-user. 14 | "--export-options", // Not intended to be used by the end-user. 15 | "--help", // Not needed. 16 | "--load-pages", // Not intended to be used by the end-user. 17 | "--no-allow-unplayable-formats", // Not intended to be used by the end-user. 18 | "--test", // Not intended to be used by the end-user. 19 | "--youtube-print-sig-code", // Not intended to be used by the end-user. 20 | } 21 | 22 | // deprecatedFlags are flags that are deprecated (but still work), and should be replaced 23 | // with alternatives (in most cases). 24 | // 25 | // - https://github.com/yt-dlp/yt-dlp/blob/master/README.md#old-aliases 26 | // - https://github.com/yt-dlp/yt-dlp/blob/master/README.md#no-longer-supported 27 | // - https://github.com/yt-dlp/yt-dlp/blob/master/README.md#sponskrub-options 28 | var deprecatedFlags = [][]string{ 29 | {"--all-formats", "Use [Command.Format] with `all` as an argument."}, 30 | {"--all-subs", "Use [Command.SubLangs] with `all` as an argument, in addition to [Command.WriteSubs]."}, 31 | {"--autonumber-size", "Use string formatting, e.g. `%(autonumber)03d`."}, 32 | {"--autonumber-start", "Use internal field formatting like `%(autonumber+NUMBER)s`."}, 33 | {"--avconv-location", "Use [Command.FfmpegLocation] instead."}, 34 | {"--break-on-reject", "Use [Command.BreakMatchFilters] instead."}, 35 | {"--call-home", "Not implemented."}, 36 | {"--clean-infojson", "Use [Command.CleanInfoJson] instead."}, 37 | {"--cn-verification-proxy", "Use [Command.GeoVerificationProxy] instead."}, 38 | {"--dump-headers", "Use [Command.PrintTraffic] instead."}, 39 | {"--dump-intermediate-pages", "Use [Command.DumpPages] instead."}, 40 | {"--exec-before-download", "Use [Command.Exec] with `before_dl:CMD` as an argument."}, 41 | {"--force-generic-extractor", "Use [Command.UseExtractors] with `generic,default` as an argument."}, 42 | {"--force-write-download-archive", "Use [Command.ForceWriteArchive] instead."}, 43 | {"--geo-bypass-country", "Use [Command.XFF] with `CODE` as an argument."}, 44 | {"--geo-bypass-ip-block", "Use [Command.XFF] with `IP_BLOCK` as an argument."}, 45 | {"--geo-bypass", "Use [Command.XFF] with `default` as an argument."}, 46 | {"--get-description", "Use [Command.Print] with `description` as an argument."}, 47 | {"--get-duration", "Use [Command.Print] with `duration_string` as an argument."}, 48 | {"--get-filename", "Use [Command.Print] with `filename` as an argument."}, 49 | {"--get-format", "Use [Command.Print] with `format` as an argument."}, 50 | {"--get-id", "Use [Command.Print] with `id` as an argument."}, 51 | {"--get-thumbnail", "Use [Command.Print] with `thumbnail` as an argument."}, 52 | {"--get-title", "Use [Command.Print] with `title` as an argument."}, 53 | {"--get-url", "Use [Command.Print] with `urls` as an argument."}, 54 | {"--hls-prefer-ffmpeg", "Use [Command.Downloader] with `m3u8:ffmpeg` as an argument."}, 55 | {"--hls-prefer-native", "Use [Command.Downloader] with `m3u8:native` as an argument."}, 56 | {"--id", "Use [Command.Output] with `%(id)s.%(ext)s` as an argument."}, 57 | {"--include-ads", "No longer supported."}, 58 | {"--list-formats-as-table", "Use [Command.ListFormatsAsTable] or [Command.CompatOptions] with `-list-formats` as an argument."}, 59 | {"--list-formats-old", "Use [Command.CompatOptions] with `list-formats` as an argument."}, 60 | {"--list-formats", "Use [Command.Print] with `formats_table` as an argument."}, 61 | {"--list-thumbnails", "Call [Command.Print] twice, once with `thumbnails_table` as an argument, then with `playlist:thumbnails_table` as an argument."}, 62 | {"--load-info", "Use [Command.LoadInfoJson] instead."}, 63 | {"--match-title", "Use [Command.MatchFilters] instead (e.g. `title ~= (?i)REGEX`)."}, 64 | {"--max-views", "Use [Command.MatchFilters] instead (e.g. `view_count <=? COUNT`)."}, 65 | {"--metadata-from-title", "Use [Command.ParseMetadata] with `%(title)s:FORMAT` as an argument."}, 66 | {"--min-views", "Use [Command.MatchFilters] instead (e.g. `view_count >=? COUNT`)."}, 67 | {"--no-call-home", "This flag is now default in yt-dlp."}, 68 | {"--no-clean-infojson", "Use [Command.NoCleanInfoJson] instead."}, 69 | {"--no-colors", "Use [Command.Color] with `no_color` as an argument."}, 70 | {"--no-exec-before-download", "Use [Command.NoExec] instead."}, 71 | {"--no-geo-bypass", "Use [Command.XFF] with `never` as an argument."}, 72 | {"--no-include-ads", "This flag is now default in yt-dlp."}, 73 | {"--no-playlist-reverse", "It is now the default behavior."}, 74 | {"--no-split-tracks", "Use [Command.NoSplitChapters] instead."}, 75 | {"--no-sponskrub-cut", "Use [Command.SponsorblockRemove] with `-all` as an argument."}, 76 | {"--no-sponskrub-force", "No longer applicable."}, 77 | {"--no-sponskrub", "Use [Command.NoSponsorblock] instead."}, 78 | {"--no-write-annotations", "This flag is now default in yt-dlp."}, 79 | {"--no-write-srt", "Use [Command.NoWriteSubs] instead."}, 80 | {"--playlist-end", "Use [Command.PlaylistItems] with `:` as an argument."}, 81 | {"--playlist-reverse", "Use [Command.PlaylistItems] with `::-1` as an argument."}, 82 | {"--playlist-start", "Use [Command.PlaylistItems] with `:` as an argument."}, 83 | {"--prefer-avconv", "avconv is not officially supported by yt-dlp."}, 84 | {"--prefer-ffmpeg", "This flag is now default in yt-dlp."}, 85 | {"--prefer-unsecure", "Use [Command.PreferInsecure] instead."}, 86 | {"--rate-limit", "Use [Command.LimitRate] instead."}, 87 | {"--referer", "Use [Command.AddHeaders] instead (e.g. `Referer:URL`)."}, 88 | {"--reject-title", "Use [Command.MatchFilters] instead (e.g. `title !~= (?i)REGEX`)."}, 89 | {"--split-tracks", "Use [Command.SplitChapters] instead."}, 90 | {"--sponskrub-args", "No longer applicable."}, 91 | {"--sponskrub-cut", "Use [Command.SponsorblockRemove] with `all` as an argument."}, 92 | {"--sponskrub-force", "No longer applicable."}, 93 | {"--sponskrub-location", "No longer applicable."}, 94 | {"--sponskrub", "Use [Command.SponsorblockMark] with `all` as an argument."}, 95 | {"--srt-lang", "Use [Command.SubLangs] instead."}, 96 | {"--trim-file-names", "Use [Command.TrimFilenames] instead."}, 97 | {"--user-agent", "Use [Command.AddHeaders] instead (e.g. `User-Agent:UA`)."}, 98 | {"--write-annotations", "No supported site has annotations now."}, 99 | {"--write-srt", "Use [Command.WriteSubs] instead."}, 100 | {"--yes-overwrites", "Use [Command.ForceOverwrites] instead."}, 101 | {"--youtube-include-dash-manifest", "Use [Command.YoutubeIncludeDashManifest] instead."}, 102 | {"--youtube-include-hls-manifest", "Use [Command.YoutubeIncludeHLSManifest] instead."}, 103 | {"--youtube-skip-dash-manifest", "Use [Command.ExtractorArgs] with `youtube:skip=dash` as an argument."}, 104 | {"--youtube-skip-hls-manifest", "Use [Command.ExtractorArgs] with `youtube:skip=hls` as an argument."}, 105 | } 106 | 107 | var linkableFlags = map[string][]OptionURL{ 108 | "--audio-multistreams": {{Name: "Format Selection", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#format-selection"}}, 109 | "--compat-options": {{Name: "Compatibility Options", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#differences-in-default-behavior"}}, 110 | "--concat-playlist": {{Name: "Output Template", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#output-template"}}, 111 | "--dump-json": {{Name: "Output Template", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#output-template"}}, 112 | "--extractor-args": {{Name: "Extractor Arguments", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#extractor-arguments"}}, 113 | "--format-sort-force": {{Name: "Sorting Formats", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#sorting-formats"}}, 114 | "--format-sort": { 115 | {Name: "Sorting Formats", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#sorting-formats"}, 116 | {Name: "Format Selection Examples", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#format-selection-examples"}, 117 | }, 118 | "--format": { 119 | {Name: "Format Selection", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#format-selection"}, 120 | {Name: "Filter Formatting", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#filtering-formats"}, 121 | {Name: "Format Selection Examples", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#format-selection-examples"}, 122 | }, 123 | "--output": {{Name: "Output Template", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#output-template"}}, 124 | "--parse-metadata": { 125 | {Name: "Modifying Metadata", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#modifying-metadata"}, 126 | {Name: "Modifying Metadata Examples", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#modifying-metadata-examples"}, 127 | }, 128 | "--replace-in-metadata": { 129 | {Name: "Modifying Metadata", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#modifying-metadata"}, 130 | {Name: "Modifying Metadata Examples", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#modifying-metadata-examples"}, 131 | }, 132 | "--split-chapters": {{Name: "Output Template", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#output-template"}}, 133 | "--update-to": {{Name: "Update Notes", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#update"}}, 134 | "--update": {{Name: "Update Notes", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#update"}}, 135 | "--video-multistreams": {{Name: "Format Selection", URL: "https://github.com/yt-dlp/yt-dlp/blob/{version}/README.md#format-selection"}}, 136 | } 137 | 138 | // knownExecutable are dest or flag names that are executable (override the default url input). 139 | var knownExecutable = []string{ 140 | "--update-to", 141 | "--update", 142 | "--version", 143 | "dump_user_agent", 144 | "list_extractor_descriptions", 145 | "list_extractors", 146 | "print_help", 147 | } 148 | 149 | var disallowedNames = []string{ 150 | "", 151 | "type", 152 | "any", 153 | "str", 154 | "int", 155 | "int32", 156 | "int64", 157 | "float", 158 | "float32", 159 | "float64", 160 | "bool", 161 | "true", 162 | "false", 163 | "none", 164 | } 165 | -------------------------------------------------------------------------------- /cmd/codegen/templates/command_json.gen.gotmpl: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | // 5 | // Code generated by cmd/codegen. DO NOT EDIT. 6 | 7 | package ytdlp 8 | 9 | import ( 10 | "encoding/json" 11 | "errors" 12 | "fmt" 13 | "slices" 14 | ) 15 | 16 | {{- define "flag-type" -}} 17 | {{- $option := . -}} 18 | {{- if (eq $option.NArgs 0) -}} 19 | bool 20 | {{- else if (eq $option.NArgs 1) -}} 21 | {{ if $option.Choices }}{{ $option.Name | to_camel }}Option{{ else }}{{ $option.Type }}{{ end }} 22 | {{- else if (gt $option.NArgs 1) -}} 23 | Flag{{ $option.Name | to_camel }} 24 | {{- end -}} 25 | {{- end -}}{{/* end define flag-type */}} 26 | 27 | {{- define "flag-jsonschema" -}} 28 | {{- $option := . -}} 29 | jsonschema:"{{"" -}} 30 | {{- range $choice := $option.Choices -}} 31 | enum={{ $choice }}, 32 | {{- end -}} 33 | title={{ $option.Name | to_camel }} 34 | {{- ""}}" 35 | {{- " " -}} 36 | jsonschema_extras:"uid={{ $option.ID }}" 37 | {{- " " -}} 38 | jsonschema_description:{{ $option.Help | quote }} 39 | {{- end -}}{{/* end define flag-jsonschema */}} 40 | 41 | {{- /* flag config, which is the entrypoint for all flag groups, validating everything, etc. */}} 42 | // FlagConfig holds all information for the flags in which to use for yt-dlp. Note that 43 | // you can technically set multiple conflicting flags through this type, however, when 44 | // the [FlagConfig.Validate] method is called, it will return a [ErrMultipleJSONParsingFlags] 45 | // error if there are any conflicts. 46 | type FlagConfig struct { 47 | {{ range $group := .OptionGroups }} 48 | {{ $group.Name | to_camel }} Flags{{ $group.Name | to_camel }} `json:"{{ $group.Name | to_snake }},omitempty,omitzero" jsonschema:"title=Group {{ $group.Name | to_camel }}"` 49 | {{- end }}{{/* end range for option groups */}} 50 | } 51 | 52 | // Clone returns a copy of the flag config. 53 | func (f *FlagConfig) Clone() *FlagConfig { 54 | // This panics if the flag config is invalid, which is a programming error, as there should be 55 | // no reason the config can have non-serializable values. 56 | v := &FlagConfig{} 57 | b, err := json.Marshal(f) 58 | if err != nil { 59 | panic(err) 60 | } 61 | err = json.Unmarshal(b, v) 62 | if err != nil { 63 | panic(err) 64 | } 65 | return v 66 | } 67 | 68 | // Validate runs validation across all flag groups. If there are validation-specific 69 | // errors, they will be returned as a [ErrMultipleJSONParsingFlags] error. Otherwise, 70 | // any other errors will be returned as a regular wrapped errors. 71 | func (f *FlagConfig) Validate() error { 72 | errs := []error{ 73 | {{- range $group := .OptionGroups }} 74 | f.{{ $group.Name | to_camel }}.Validate(), 75 | {{- end }}{{/* end range for option groups */}} 76 | } 77 | 78 | var regularErrs []error 79 | var validationErrs []*ErrJSONParsingFlag 80 | 81 | for _, err := range errs { 82 | if err == nil { 83 | continue 84 | } 85 | if verr, ok := IsJSONParsingFlagError(err); ok { 86 | validationErrs = append(validationErrs, verr) 87 | } else { 88 | regularErrs = append(regularErrs, err) 89 | } 90 | } 91 | 92 | if len(validationErrs) > 0 { 93 | return &ErrMultipleJSONParsingFlags{Errors: validationErrs} 94 | } 95 | if len(regularErrs) > 0 { 96 | return errors.Join(regularErrs...) 97 | } 98 | return nil 99 | } 100 | 101 | func (f *FlagConfig) ToFlags() (flags Flags) { 102 | {{- range $group := .OptionGroups }} 103 | flags = append(flags, f.{{ $group.Name | to_camel }}.ToFlags()...) 104 | {{- end }}{{/* end range for option groups */}} 105 | 106 | // Deduplicate flags by their ID, where only the last one is kept, and the others are deleted. 107 | for i := 0; i < len(flags); i++ { 108 | if flags[i].AllowsMultiple { 109 | continue 110 | } 111 | 112 | for j := i + 1; j < len(flags); j++ { 113 | if flags[j].AllowsMultiple { 114 | continue 115 | } 116 | 117 | if flags[i].ID == flags[j].ID { 118 | flags[j] = nil 119 | flags = append(flags[:j], flags[j+1:]...) 120 | } 121 | } 122 | } 123 | return flags 124 | } 125 | 126 | {{- /* 127 | flag groups, which are the entrypoint for all flags in a group, validating 128 | everything, etc. 129 | */}} 130 | {{ range $group := .OptionGroups }} 131 | type Flags{{ $group.Name | to_camel }} struct { 132 | {{- range $option := .Options -}} 133 | {{- if $option.Executable -}}{{ continue }}{{- end }} 134 | {{- if $option.Help }} 135 | // {{ wrap 90 $option.Help | replace "\n" "\n// " }} 136 | {{- end }} 137 | {{ $option.Name | to_camel }} 138 | {{- " " }} 139 | {{- if $option.AllowsMultiple }}[]{{ end }} 140 | {{- if (or (not $option.AllowsMultiple) (gt $option.NArgs 1)) }}*{{ end }} 141 | {{- template "flag-type" $option }} 142 | {{- " " }}`json:"{{ $option.Name | to_snake }},omitempty" id:{{ $option.ID | quote }} {{ template "flag-jsonschema" $option }}` 143 | {{- end }}{{/* end range for options */}} 144 | } 145 | 146 | {{- /* individual flag structs for flags that can't be represented by a single type. */}} 147 | {{ range $option := .Options -}} 148 | {{- if (gt $option.NArgs 1) -}} 149 | type Flag{{ $option.Name | to_camel }} struct { 150 | {{ range $arg := $option.ArgNames }} 151 | {{ $arg | to_camel }} {{ $option.Type }} `json:"{{ $arg | to_snake }},omitempty" {{ template "flag-jsonschema" $option }}` 152 | {{- end }}{{/* end range for args */}} 153 | } 154 | {{- end }}{{/* end if nargs */}} 155 | {{ end }}{{/* end range for options */}} 156 | 157 | // Validate ensures all flags have appropriate values. If there are validation-specific 158 | // errors, they will be returned as a [ErrMultipleJSONParsingFlags] error. 159 | func (g *Flags{{ $group.Name | to_camel }}) Validate() error { 160 | if g == nil { 161 | return nil 162 | } 163 | 164 | var validationErrs []*ErrJSONParsingFlag 165 | {{- range $option := .Options -}} 166 | {{- if $option.Executable }}{{ continue }}{{ end }} 167 | 168 | {{- if $option.Choices }} 169 | if g.{{ $option.Name | to_camel }} != nil { 170 | if !slices.Contains(All{{ $option.Name | to_camel }}Options, *g.{{ $option.Name | to_camel }}) { 171 | validationErrs = append(validationErrs, &ErrJSONParsingFlag{ 172 | JSONPath: "{{ $group.Name | to_snake }}.{{ $option.Name | to_snake }}", 173 | Flag: {{ $option.Flag | quote }}, 174 | ID: {{ $option.ID | quote }}, 175 | Err: fmt.Errorf( 176 | "invalid value for {{ $group.Name | to_snake }}.{{ $option.Name | to_snake }}: %q (expected one of: %v)", 177 | *g.{{ $option.Name | to_camel }}, 178 | All{{ $option.Name | to_camel }}Options, 179 | ), 180 | }) 181 | } 182 | } 183 | {{- end }} 184 | {{- end }}{{/* end range for options */}} 185 | 186 | {{/* check for duplicates */}} 187 | duplicates := g.ToFlags().Duplicates() 188 | for _, duplicate := range duplicates { 189 | validationErrs = append(validationErrs, &ErrJSONParsingFlag{ 190 | JSONPath: "{{ $group.Name | to_snake }}." + duplicate.ID, 191 | Flag: duplicate.Flag, 192 | ID: duplicate.ID, 193 | Err: fmt.Errorf("duplicate flag (with conflicting ID %q) found: %v", duplicate.ID, duplicate.Flag), 194 | }) 195 | } 196 | 197 | if len(validationErrs) > 0 { 198 | return &ErrMultipleJSONParsingFlags{Errors: validationErrs} 199 | } 200 | return nil 201 | } 202 | 203 | // ToFlags returns the generated flags based off the provided configuration. [Flags{{ $group.Name | to_camel }}.Validate] 204 | // should be called first. 205 | func (g *Flags{{ $group.Name | to_camel }}) ToFlags() (flags Flags) { 206 | if g == nil { 207 | return flags 208 | } 209 | 210 | {{- range $option := .Options -}} 211 | {{- if $option.Executable }}{{ continue }}{{ end }} 212 | {{- if $option.AllowsMultiple }} 213 | for _, v := range g.{{ $option.Name | to_camel }} { 214 | flags = append(flags, &Flag{ 215 | {{- ""}}ID: {{ $option.ID | quote }}, 216 | {{- ""}}Flag: {{ $option.Flag | quote }}, 217 | {{- ""}}AllowsMultiple: true, 218 | {{- ""}}Args: 219 | {{- if (eq $option.NArgs 1) }} 220 | {{- if $option.Choices }} 221 | []any{string(v)}, 222 | {{- else }} 223 | []any{v}, 224 | {{- end }} 225 | {{- else if (gt $option.NArgs 1) }} 226 | []any{ 227 | {{- range $arg := $option.ArgNames -}} 228 | v.{{ $arg | to_camel }}, 229 | {{- end }} 230 | } 231 | {{- end }} 232 | {{- ""}}}) 233 | } 234 | {{- else }} 235 | if g.{{ $option.Name | to_camel }} != nil {{ if (eq $option.NArgs 0) }} && *g.{{ $option.Name | to_camel }}{{ end }} { 236 | flags = append(flags, &Flag{ 237 | {{- ""}}ID: {{ $option.ID | quote }}, 238 | {{- ""}}Flag: {{ $option.Flag | quote }}, 239 | {{- ""}}Args: 240 | {{- if (eq $option.NArgs 0) }} 241 | nil, 242 | {{- else if (eq $option.NArgs 1) }} 243 | {{- if $option.Choices }} 244 | []any{string(*g.{{ $option.Name | to_camel }})}, 245 | {{- else }} 246 | []any{*g.{{ $option.Name | to_camel }}}, 247 | {{- end }} 248 | {{- else if (gt $option.NArgs 1) }} 249 | []any{ 250 | {{- range $arg := $option.ArgNames }} 251 | (*g.{{ $option.Name | to_camel }}).{{ $arg | to_camel }}, 252 | {{- end }} 253 | }, 254 | {{- end }} 255 | {{- ""}}}) 256 | } 257 | {{- end }} 258 | {{- end }}{{/* end range for options */}} 259 | return flags 260 | } 261 | {{ end }}{{/* end range for option groups */}} 262 | -------------------------------------------------------------------------------- /install_ffmpeg.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package ytdlp 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "errors" 11 | "fmt" 12 | "os/exec" 13 | "path/filepath" 14 | "regexp" 15 | "runtime" 16 | "strings" 17 | "sync" 18 | "sync/atomic" 19 | ) 20 | 21 | var ( 22 | ffmpegResolveCache = atomic.Pointer[ResolvedInstall]{} // Should only be used by [InstallFFmpeg]. 23 | ffmpegInstallLock sync.Mutex 24 | ffprobeResolveCache = atomic.Pointer[ResolvedInstall]{} // Should only be used by [InstallFFprobe]. 25 | ffprobeInstallLock sync.Mutex 26 | 27 | ffmpegBinConfigs = map[string]ffmpegBinConfig{ 28 | "darwin_amd64": { 29 | ffmpegURL: "https://evermeet.cx/ffmpeg/getrelease/ffmpeg", 30 | ffprobeURL: "https://evermeet.cx/ffmpeg/getrelease/ffprobe", 31 | ffmpeg: "ffmpeg", 32 | ffprobe: "ffprobe", 33 | isArchive: false, 34 | }, 35 | "linux_amd64": { 36 | ffmpegURL: "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz", 37 | ffmpeg: "ffmpeg", 38 | ffprobe: "ffprobe", 39 | isArchive: true, 40 | }, 41 | "linux_arm64": { 42 | ffmpegURL: "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarm64-gpl.tar.xz", 43 | ffmpeg: "ffmpeg", 44 | ffprobe: "ffprobe", 45 | isArchive: true, 46 | }, 47 | "windows_amd64": { 48 | ffmpegURL: "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip", 49 | ffmpeg: "ffmpeg.exe", 50 | ffprobe: "ffprobe.exe", 51 | isArchive: true, 52 | }, 53 | "windows_arm": { 54 | ffmpegURL: "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-winarm64-gpl.zip", 55 | ffmpeg: "ffmpeg.exe", 56 | ffprobe: "ffprobe.exe", 57 | isArchive: true, 58 | }, 59 | } 60 | ) 61 | 62 | type ffmpegBinConfig struct { 63 | ffmpegURL string 64 | ffprobeURL string 65 | ffmpeg string 66 | ffprobe string 67 | isArchive bool // true if the download is an archive containing both binaries 68 | } 69 | 70 | type InstallFFmpegOptions struct { 71 | // DisableDownload is a simple toggle to never allow downloading, which would 72 | // be the same as never calling [InstallFFmpeg] or [MustInstallFFmpeg] in the first place. 73 | DisableDownload bool 74 | 75 | // DisableSystem is a simple toggle to never allow resolving from the system PATH. 76 | DisableSystem bool 77 | 78 | // DownloadURL is the exact url to the binary location to download (and store). 79 | // Leave empty to use GitHub (windows, linux) and evermeet.cx (macos) + 80 | // auto-detected os/arch. 81 | DownloadURL string 82 | } 83 | 84 | // MustInstallFFmpeg is similar to [InstallFFmpeg], but panics if there is an error. 85 | func MustInstallFFmpeg(ctx context.Context, opts *InstallFFmpegOptions) { 86 | _, err := InstallFFmpeg(ctx, opts) 87 | if err != nil { 88 | panic(err) 89 | } 90 | } 91 | 92 | // InstallFFmpeg will attempt to download and install FFmpeg for the current platform. 93 | // If the binary is already installed or found in the PATH, it will return the resolved 94 | // binary unless [InstallFFmpegOptions.DisableSystem] is set to true. Note that 95 | // downloading of ffmpeg and ffprobe is only supported on a handful of platforms, and so 96 | // it is still recommended to install ffmpeg/ffprobe via other means. 97 | func InstallFFmpeg(ctx context.Context, opts *InstallFFmpegOptions) (*ResolvedInstall, error) { 98 | ffmpegInstallLock.Lock() 99 | defer ffmpegInstallLock.Unlock() 100 | 101 | if opts == nil { 102 | opts = &InstallFFmpegOptions{} 103 | } 104 | 105 | if cached := ffmpegResolveCache.Load(); cached != nil { 106 | return cached, nil 107 | } 108 | 109 | _, binaries, _ := ffmpegGetDownloadBinary() // don't check error yet. 110 | resolved, err := resolveExecutable(ctx, false, opts.DisableSystem, binaries) 111 | if err == nil { 112 | if resolved.Version == "" { 113 | err = ffGetVersion(ctx, resolved) 114 | if err != nil { 115 | return nil, err 116 | } 117 | } 118 | 119 | ffmpegResolveCache.Store(resolved) 120 | return resolved, nil 121 | } 122 | 123 | if opts.DisableDownload { 124 | return nil, errors.New("ffmpeg binary not found, and downloading is disabled") 125 | } 126 | 127 | // Download and install FFmpeg (and FFprobe if archive). 128 | resolved, err = downloadAndInstallFFmpeg(ctx, opts) 129 | if err != nil { 130 | return nil, err 131 | } 132 | 133 | ffmpegResolveCache.Store(resolved) 134 | return resolved, nil 135 | } 136 | 137 | // MustInstallFFprobe is similar to [InstallFFprobe], but panics if there is an error. 138 | func MustInstallFFprobe(ctx context.Context, opts *InstallFFmpegOptions) { 139 | _, err := InstallFFprobe(ctx, opts) 140 | if err != nil { 141 | panic(err) 142 | } 143 | } 144 | 145 | // InstallFFprobe will attempt to download and install FFprobe for the current platform. 146 | // If the binary is already installed or found in the PATH, it will return the resolved 147 | // binary unless [InstallFFmpegOptions.DisableSystem] is set to true. Note that 148 | // downloading of ffmpeg and ffprobe is only supported on a handful of platforms, and so 149 | // it is still recommended to install ffmpeg/ffprobe via other means. 150 | func InstallFFprobe(ctx context.Context, opts *InstallFFmpegOptions) (*ResolvedInstall, error) { 151 | ffprobeInstallLock.Lock() 152 | defer ffprobeInstallLock.Unlock() 153 | 154 | if opts == nil { 155 | opts = &InstallFFmpegOptions{} 156 | } 157 | 158 | if cached := ffprobeResolveCache.Load(); cached != nil { 159 | return cached, nil 160 | } 161 | 162 | _, binaries, _ := ffprobeGetDownloadBinary() // don't check error yet. 163 | resolved, err := resolveExecutable(ctx, false, opts.DisableSystem, binaries) 164 | if err == nil { 165 | if resolved.Version == "" { 166 | err = ffGetVersion(ctx, resolved) 167 | if err != nil { 168 | return nil, err 169 | } 170 | } 171 | 172 | ffprobeResolveCache.Store(resolved) 173 | return resolved, nil 174 | } 175 | 176 | if opts.DisableDownload { 177 | return nil, errors.New("ffprobe binary not found, and downloading is disabled") 178 | } 179 | 180 | // Download and install FFprobe (and FFmpeg if archive). 181 | resolved, err = downloadAndInstallFFprobe(ctx, opts) 182 | if err != nil { 183 | return nil, err 184 | } 185 | 186 | ffprobeResolveCache.Store(resolved) 187 | return resolved, nil 188 | } 189 | 190 | func downloadAndInstallFFmpeg(ctx context.Context, opts *InstallFFmpegOptions) (*ResolvedInstall, error) { 191 | src, destBinaries, err := ffmpegGetDownloadBinary() 192 | if err != nil { 193 | return nil, err 194 | } 195 | 196 | config, ok := ffmpegBinConfigs[src] 197 | if !ok { 198 | return nil, fmt.Errorf("no ffmpeg download configuration for %s", src) 199 | } 200 | 201 | cacheDir, err := createCacheDir(ctx) 202 | if err != nil { 203 | return nil, err 204 | } 205 | 206 | downloadURL := opts.DownloadURL 207 | if downloadURL == "" { 208 | downloadURL = config.ffmpegURL 209 | } 210 | 211 | destPath := filepath.Join(cacheDir, destBinaries[0]) 212 | 213 | if config.isArchive { 214 | // Download and extract archive. 215 | err = downloadAndExtractFilesFromArchive(ctx, downloadURL, cacheDir, []string{config.ffmpeg, config.ffprobe}) 216 | if err != nil { 217 | return nil, fmt.Errorf("failed to download and extract ffmpeg archive: %w", err) 218 | } 219 | } else { 220 | // Download single binary. 221 | destPath, err = downloadFile(ctx, downloadURL, cacheDir, destPath, 0o700) 222 | if err != nil { 223 | return nil, fmt.Errorf("failed to download ffmpeg: %w", err) 224 | } 225 | } 226 | 227 | return &ResolvedInstall{ 228 | Executable: destPath, 229 | FromCache: false, 230 | Downloaded: true, 231 | }, nil 232 | } 233 | 234 | func downloadAndInstallFFprobe(ctx context.Context, opts *InstallFFmpegOptions) (*ResolvedInstall, error) { 235 | src, destBinaries, err := ffprobeGetDownloadBinary() 236 | if err != nil { 237 | return nil, err 238 | } 239 | 240 | config, ok := ffmpegBinConfigs[src] 241 | if !ok { 242 | return nil, fmt.Errorf("no ffprobe download configuration for %s", src) 243 | } 244 | 245 | cacheDir, err := createCacheDir(ctx) 246 | if err != nil { 247 | return nil, err 248 | } 249 | 250 | destPath := filepath.Join(cacheDir, destBinaries[0]) 251 | 252 | if config.isArchive { 253 | // Download and extract archive (contains both ffmpeg and ffprobe). 254 | downloadURL := opts.DownloadURL 255 | if downloadURL == "" { 256 | downloadURL = config.ffmpegURL // Use ffmpeg URL for archive. 257 | } 258 | 259 | err = downloadAndExtractFilesFromArchive(ctx, downloadURL, cacheDir, []string{config.ffmpeg, config.ffprobe}) 260 | if err != nil { 261 | return nil, fmt.Errorf("failed to download and extract ffprobe archive: %w", err) 262 | } 263 | } else { 264 | // Download single binary. 265 | downloadURL := opts.DownloadURL 266 | if downloadURL == "" { 267 | downloadURL = config.ffprobeURL 268 | } 269 | 270 | destPath, err = downloadFile(ctx, downloadURL, cacheDir, "", 0o700) 271 | if err != nil { 272 | return nil, fmt.Errorf("failed to download ffprobe: %w", err) 273 | } 274 | } 275 | 276 | return &ResolvedInstall{ 277 | Executable: destPath, 278 | FromCache: false, 279 | Downloaded: true, 280 | }, nil 281 | } 282 | 283 | func ffmpegGetDownloadBinary() (src string, dest []string, err error) { 284 | src = runtime.GOOS + "_" + runtime.GOARCH 285 | if binary, ok := ffmpegBinConfigs[src]; ok { 286 | return src, []string{binary.ffmpeg}, nil 287 | } 288 | 289 | var supported []string 290 | for k := range ffmpegBinConfigs { 291 | supported = append(supported, k) 292 | } 293 | 294 | if runtime.GOOS == "windows" { 295 | dest = []string{"ffmpeg.exe"} 296 | } else { 297 | dest = []string{"ffmpeg"} 298 | } 299 | 300 | return src, dest, fmt.Errorf( 301 | "unsupported os/arch combo: %s/%s (supported: %s)", 302 | runtime.GOOS, 303 | runtime.GOARCH, 304 | strings.Join(supported, ", "), 305 | ) 306 | } 307 | 308 | func ffprobeGetDownloadBinary() (src string, dest []string, err error) { 309 | src = runtime.GOOS + "_" + runtime.GOARCH 310 | if binary, ok := ffmpegBinConfigs[src]; ok { 311 | return src, []string{binary.ffprobe}, nil 312 | } 313 | 314 | var supported []string 315 | for k := range ffmpegBinConfigs { 316 | supported = append(supported, k) 317 | } 318 | 319 | if runtime.GOOS == "windows" { 320 | dest = []string{"ffprobe.exe"} 321 | } else { 322 | dest = []string{"ffprobe"} 323 | } 324 | 325 | return src, dest, fmt.Errorf( 326 | "unsupported os/arch combo: %s/%s (supported: %s)", 327 | runtime.GOOS, 328 | runtime.GOARCH, 329 | strings.Join(supported, ", "), 330 | ) 331 | } 332 | 333 | var ffmpegVersionRegex = regexp.MustCompile(`^(?:ffmpeg|ffprobe) version ([^ ]+) .*`) 334 | 335 | func ffGetVersion(ctx context.Context, r *ResolvedInstall) error { 336 | var stdout bytes.Buffer 337 | 338 | cmd := exec.Command(r.Executable, "-version") //nolint:gosec 339 | cmd.Stdout = &stdout 340 | applySyscall(cmd, false) 341 | 342 | if err := cmd.Run(); err != nil { 343 | return fmt.Errorf("unable to run %s to verify version: %w", r.Executable, err) 344 | } 345 | 346 | version := ffmpegVersionRegex.FindStringSubmatch(stdout.String()) 347 | if len(version) < 2 { 348 | return fmt.Errorf("unable to parse %s version from output", r.Executable) 349 | } 350 | 351 | r.Version = version[1] 352 | debug(ctx, "resolved version", "binary", r.Executable, "version", r.Version) 353 | return nil 354 | } 355 | -------------------------------------------------------------------------------- /command.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) Liam Stanley . All rights reserved. Use of 2 | // this source code is governed by the MIT license that can be found in 3 | // the LICENSE file. 4 | 5 | package ytdlp 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | "maps" 11 | "os" 12 | "os/exec" 13 | "path/filepath" 14 | "runtime" 15 | "slices" 16 | "strconv" 17 | "strings" 18 | "sync" 19 | "time" 20 | ) 21 | 22 | // New is the recommended way to return a new yt-dlp command builder. Once all 23 | // flags are set, you can call [Run] to invoke yt-dlp with the necessary args, or 24 | // the independent execution method (e.g. [Version]). 25 | func New() *Command { 26 | cmd := &Command{ 27 | env: make(map[string]string), 28 | flagConfig: &FlagConfig{}, 29 | cancelMaxWait: 1 * time.Second, 30 | } 31 | return cmd 32 | } 33 | 34 | type Command struct { 35 | mu sync.RWMutex 36 | executable string 37 | directory string 38 | env map[string]string 39 | flagConfig *FlagConfig 40 | separateProcessGroup bool 41 | cancelMaxWait time.Duration 42 | disableEnvVarInherit bool 43 | 44 | progress *progressHandler 45 | } 46 | 47 | // Clone returns a copy of the command, with all flags, env vars, executable, 48 | // working directory, etc copied over. 49 | func (c *Command) Clone() *Command { 50 | c.mu.RLock() 51 | cc := &Command{ 52 | executable: c.executable, 53 | directory: c.directory, 54 | env: make(map[string]string, len(c.env)), 55 | flagConfig: c.flagConfig.Clone(), 56 | separateProcessGroup: c.separateProcessGroup, 57 | progress: c.progress, 58 | } 59 | maps.Copy(cc.env, c.env) 60 | c.mu.RUnlock() 61 | return cc 62 | } 63 | 64 | // GetFlagConfig returns a copy of the flag config. 65 | func (c *Command) GetFlagConfig() *FlagConfig { 66 | c.mu.RLock() 67 | cc := c.flagConfig.Clone() 68 | c.mu.RUnlock() 69 | return cc 70 | } 71 | 72 | // SetFlagConfig sets the flag config for the command, overriding ALL previously 73 | // set flags. If nil is provided, a new empty flag config will be used. 74 | func (c *Command) SetFlagConfig(flagConfig *FlagConfig) *Command { 75 | if flagConfig == nil { 76 | flagConfig = &FlagConfig{} 77 | } 78 | c.mu.Lock() 79 | c.flagConfig = flagConfig.Clone() 80 | c.mu.Unlock() 81 | return c 82 | } 83 | 84 | // SetExecutable sets the executable path to yt-dlp for the command. 85 | func (c *Command) SetExecutable(path string) *Command { 86 | c.mu.Lock() 87 | c.executable = path 88 | c.mu.Unlock() 89 | 90 | return c 91 | } 92 | 93 | // SetWorkDir sets the working directory for the command. Defaults to current working 94 | // directory. 95 | func (c *Command) SetWorkDir(path string) *Command { 96 | c.mu.Lock() 97 | c.directory = path 98 | c.mu.Unlock() 99 | 100 | return c 101 | } 102 | 103 | // SetEnvVar sets an environment variable for the command. If value is empty, it will 104 | // be removed. If the key is "PATH", it will be merged with the parent process's PATH, 105 | // (where explicit values provided here will take precedence). See also [SetEnvVarInherit]. 106 | func (c *Command) SetEnvVar(key, value string) *Command { 107 | c.mu.Lock() 108 | if value == "" { 109 | delete(c.env, key) 110 | } else { 111 | c.env[key] = value 112 | } 113 | c.mu.Unlock() 114 | 115 | return c 116 | } 117 | 118 | // SetSeparateProcessGroup sets whether the command should be run in a separate 119 | // process group. This is useful to avoid propagating signals from the app process. 120 | // NOTE: This is only supported on Windows and Unix-like systems. 121 | func (c *Command) SetSeparateProcessGroup(value bool) *Command { 122 | c.mu.Lock() 123 | c.separateProcessGroup = value 124 | c.mu.Unlock() 125 | 126 | return c 127 | } 128 | 129 | // SetCancelMaxWait sets the maximum wait time before the command is killed, 130 | // after the context is cancelled. Defaults to 1 second. 131 | func (c *Command) SetCancelMaxWait(value time.Duration) *Command { 132 | c.mu.Lock() 133 | c.cancelMaxWait = value 134 | c.mu.Unlock() 135 | return c 136 | } 137 | 138 | // SetEnvVarInherit sets whether the command should inherit environment variables 139 | // from the parent process. If enabled, the command will inherit the parent process's 140 | // environment variables, and any environment variables set with [SetEnvVar] will be 141 | // merged with the parent process's environment variables. If disabled, the command 142 | // will only use the environment variables set with [SetEnvVar] (with the exception 143 | // of PATH). Explicitly set env vars with [SetEnvVar] will always take precedence 144 | // over the parent process's environment variables. 145 | func (c *Command) SetEnvVarInherit(enabled bool) *Command { 146 | c.mu.Lock() 147 | c.disableEnvVarInherit = !enabled 148 | c.mu.Unlock() 149 | return c 150 | } 151 | 152 | func (c *Command) hasJSONFlag() bool { 153 | if slices.Contains(c.flagConfig.VerbositySimulation.Print, "%(j)") && len(c.flagConfig.VerbositySimulation.Print) == 1 { 154 | return true 155 | } 156 | if v := c.flagConfig.VerbositySimulation.PrintJSON; v != nil && *v { 157 | return true 158 | } 159 | if v := c.flagConfig.VerbositySimulation.DumpJSON; v != nil && *v { 160 | return true 161 | } 162 | return false 163 | } 164 | 165 | // toMap converts a slice of environment variables to a map. Handles Windows 166 | // environment variables that start with '='. 167 | func toMap(env []string) map[string]string { 168 | r := map[string]string{} 169 | for _, e := range env { 170 | p := strings.SplitN(e, "=", 2) 171 | 172 | if runtime.GOOS == "windows" { 173 | // On Windows, env vars can start with "=". 174 | prefix := false 175 | if len(e) > 0 && e[0] == '=' { 176 | e = e[1:] 177 | prefix = true 178 | } 179 | p = strings.SplitN(e, "=", 2) 180 | if prefix { 181 | p[0] = "=" + p[0] 182 | } 183 | } 184 | 185 | if len(p) == 2 { 186 | r[p[0]] = p[1] 187 | } 188 | } 189 | return r 190 | } 191 | 192 | // BuildCommand builds the command to be executed. args passed here are any additional 193 | // arguments to be passed to yt-dlp (commonly URLs or similar). This should not be used 194 | // directly unless you want to reference the arguments passed to yt-dlp. 195 | func (c *Command) BuildCommand(ctx context.Context, args ...string) *exec.Cmd { 196 | var cmdArgs []string 197 | 198 | for _, f := range c.flagConfig.ToFlags() { 199 | cmdArgs = append(cmdArgs, f.Raw()...) 200 | } 201 | 202 | cmdArgs = append(cmdArgs, args...) // URLs or similar. 203 | 204 | var name string 205 | var err error 206 | 207 | c.mu.RLock() 208 | name = c.executable 209 | 210 | if name == "" { 211 | r := ytdlpResolveCache.Load() 212 | if r == nil { 213 | _, binaries, _ := ytdlpGetDownloadBinary() // don't check error yet. 214 | r, err = resolveExecutable(ctx, false, false, binaries) 215 | if err == nil { 216 | name = r.Executable 217 | } 218 | } else { 219 | name = r.Executable 220 | } 221 | } 222 | 223 | env := map[string]string{} 224 | if !c.disableEnvVarInherit { 225 | env = toMap(os.Environ()) 226 | } 227 | 228 | // Merge in the command's environment variables, accounting for things like 229 | // PATH, which should be merged with the previously provided PATH. 230 | for k, v := range c.env { 231 | switch k { 232 | case "PATH": 233 | if env["PATH"] != "" { 234 | paths := filepath.SplitList(env["PATH"]) 235 | cpaths := filepath.SplitList(v) 236 | // Append parent process paths to the end of our custom provided 237 | // paths, only if they are not already in the PATH. 238 | for _, p := range paths { 239 | if !slices.Contains(cpaths, p) { 240 | cpaths = append([]string{p}, cpaths...) 241 | } 242 | } 243 | env["PATH"] = strings.Join(cpaths, string(filepath.ListSeparator)) 244 | } else { 245 | env["PATH"] = v 246 | } 247 | default: 248 | env[k] = v 249 | } 250 | } 251 | 252 | cmd := exec.CommandContext(ctx, name, cmdArgs...) 253 | 254 | // Ensure all children (e.g. ffmpeg) are killed after the command is killed. 255 | cmd.WaitDelay = c.cancelMaxWait 256 | 257 | // Add cache directory to $PATH, which would cover ffmpeg, ffprobe, etc. 258 | cacheDir, err := GetCacheDir() 259 | if err == nil { 260 | env["PATH"] = strings.Join(append([]string{cacheDir}, filepath.SplitList(env["PATH"])...), string(filepath.ListSeparator)) 261 | } 262 | 263 | if err != nil { 264 | cmd.Err = err // Hijack the existing command to return the error from resolveExecutable. 265 | } 266 | 267 | if c.directory != "" { 268 | cmd.Dir = c.directory 269 | } 270 | 271 | cmd.Env = make([]string, 0, len(env)) 272 | for k, v := range env { 273 | cmd.Env = append(cmd.Env, k+"="+v) 274 | } 275 | c.mu.RUnlock() 276 | 277 | return cmd 278 | } 279 | 280 | // runWithResult runs the provided command, collects stdout/stderr, massages the 281 | // result into a Result struct, and returns it (with error wrapping). 282 | func (c *Command) runWithResult(ctx context.Context, cmd *exec.Cmd) (*Result, error) { 283 | if cmd.Err != nil { 284 | return wrapError(nil, cmd.Err) 285 | } 286 | 287 | stdout := ×tampWriter{pipe: "stdout", progress: c.progress} 288 | stderr := ×tampWriter{pipe: "stderr"} 289 | 290 | if c.hasJSONFlag() { 291 | stdout.checkJSON = true 292 | stderr.checkJSON = true 293 | } 294 | 295 | cmd.Stdout = stdout 296 | cmd.Stderr = stderr 297 | 298 | applySyscall(cmd, c.separateProcessGroup) 299 | 300 | debug( 301 | ctx, "running command", 302 | "path", cmd.Path, 303 | "args", cmd.Args, 304 | "env", cmd.Env, 305 | "dir", cmd.Dir, 306 | ) 307 | 308 | err := cmd.Run() 309 | 310 | result := &Result{ 311 | Executable: cmd.Path, 312 | Args: cmd.Args[1:], 313 | ExitCode: cmd.ProcessState.ExitCode(), 314 | Stdout: stdout.String(), 315 | Stderr: stderr.String(), 316 | OutputLogs: stdout.mergeResults(stderr), 317 | } 318 | 319 | return wrapError(result, err) 320 | } 321 | 322 | // Run invokes yt-dlp with the provided arguments (and any flags previously set), 323 | // and returns the results (stdout/stderr, exit code, etc). args should be the 324 | // URLs that would normally be passed in to yt-dlp. 325 | func (c *Command) Run(ctx context.Context, args ...string) (*Result, error) { 326 | if err := c.flagConfig.Validate(); err != nil { 327 | return nil, err 328 | } 329 | 330 | cmd := c.BuildCommand(ctx, args...) 331 | return c.runWithResult(ctx, cmd) 332 | } 333 | 334 | type Flag struct { 335 | ID string `json:"id"` // Unique ID to ensure boolean flags are not duplicated. 336 | Flag string `json:"flag"` // Actual flag, e.g. "--version". 337 | AllowsMultiple bool `json:"allows_multiple"` // If the flag allows multiple values. 338 | Args []any `json:"args"` // Optional args. If nil, it's a boolean flag. 339 | } 340 | 341 | func (f *Flag) Raw() (args []string) { 342 | args = append(args, f.Flag) 343 | if f.Args == nil { 344 | return args 345 | } 346 | 347 | for _, arg := range f.Args { 348 | if arg == nil { 349 | continue 350 | } 351 | 352 | switch arg := arg.(type) { 353 | case string: 354 | args = append(args, arg) 355 | case int: 356 | args = append(args, strconv.Itoa(arg)) 357 | case int64: 358 | args = append(args, strconv.FormatInt(arg, 10)) 359 | case float64: 360 | args = append(args, strconv.FormatFloat(arg, 'g', -1, 64)) 361 | case bool: 362 | args = append(args, strconv.FormatBool(arg)) 363 | default: 364 | panic(fmt.Sprintf("unsupported arg type for flag: %T", arg)) 365 | } 366 | } 367 | 368 | return args 369 | } 370 | 371 | type Flags []*Flag 372 | 373 | func (f Flags) FindByID(id string) (flags Flags) { 374 | for _, flag := range f { 375 | if flag.ID == id { 376 | flags = append(flags, flag) 377 | } 378 | } 379 | return flags 380 | } 381 | 382 | func (f Flags) Duplicates() (duplicates Flags) { 383 | seen := make(map[string]Flags) 384 | for _, flag := range f { 385 | if flag.AllowsMultiple { 386 | continue 387 | } 388 | seen[flag.ID] = append(seen[flag.ID], flag) 389 | } 390 | for _, flags := range seen { 391 | if len(flags) > 1 { 392 | duplicates = append(duplicates, flags...) 393 | } 394 | } 395 | return duplicates 396 | } 397 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 6 | ![logo](https://liam.sh/-/gh/svg/lrstanley/go-ytdlp?layout=left&icon=logos%3Ayoutube-icon&icon.height=70&font=1.2&bg=geometric&bgcolor=rgba%2833%2C+33%2C+33%2C+1%29) 7 | 8 | 9 | 10 | 11 |

12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |

39 |

40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 |

54 | 55 | 56 | 57 | 58 | ## :link: Table of Contents 59 | 60 | - [Features](#sparkles-features) 61 | - [Help Documentation Example](#sparkles-help-documentation-example) 62 | - [Usage](#gear-usage) 63 | - [Examples](#clap-examples) 64 | - [Simple](#simple) 65 | - [Fancy UI using BubbleTea](#fancy-ui-using-bubbletea) 66 | - [Install Function(s) & Binary Management](#package-install-functions--binary-management) 67 | - [yt-dlp](#yt-dlp) 68 | - [ffmpeg & ffprobe](#ffmpeg--ffprobe) 69 | - [FlagConfig: JSON to/from Flags Conversion & Usage](#flagconfig-json-tofrom-flags-conversion--usage) 70 | - [Support & Assistance](#raising_hand_man-support--assistance) 71 | - [Contributing](#handshake-contributing) 72 | - [License](#balance_scale-license) 73 | 74 | 75 | ## :sparkles: Features 76 | 77 | - CLI bindings for yt-dlp -- including all flags/commands. 78 | - Optional `Install*` helpers to auto-download the latest supported version of 79 | yt-dlp, ffmpeg and ffprobe, including proper checksum validation for secure downloads (yt-dlp only). 80 | - Worry less about making sure yt-dlp is installed wherever **go-ytdlp** is running from! 81 | - Carried over help documentation for all functions/methods. 82 | - Flags with arguments have type mappings according to what the actual flags expect. 83 | - Completely generated, ensuring it's easy to update to future **yt-dlp** versions. 84 | - Deprecated flags are marked as deprecated in a way that should be caught by most IDEs/linters. 85 | - Stdout/Stderr parsing, with timestamps, and optional JSON post-processing. 86 | 87 | ### :sparkles: Help Documentation Example 88 | 89 | ![help documentation example](https://cdn.liam.sh/share/2023/09/Code_m1wz0zsCj9.png) 90 | 91 | --- 92 | 93 | ## :gear: Usage 94 | 95 | 96 | 97 | ```console 98 | go get -u github.com/lrstanley/go-ytdlp@latest 99 | ``` 100 | 101 | 102 | ## :clap: Examples 103 | 104 | ### Simple 105 | 106 | See also [_examples/simple/main.go](./_examples/simple/main.go), which includes 107 | writing results (stdout/stderr/etc) as JSON. 108 | 109 | ```go 110 | package main 111 | 112 | import ( 113 | "context" 114 | 115 | "github.com/lrstanley/go-ytdlp" 116 | ) 117 | 118 | func main() { 119 | // If yt-dlp isn't installed yet, download and cache it for further use. 120 | ytdlp.MustInstall(context.TODO(), nil) 121 | 122 | dl := ytdlp.New(). 123 | FormatSort("res,ext:mp4:m4a"). 124 | RecodeVideo("mp4"). 125 | Output("%(extractor)s - %(title)s.%(ext)s") 126 | 127 | _, err := dl.Run(context.TODO(), "https://www.youtube.com/watch?v=dQw4w9WgXcQ") 128 | if err != nil { 129 | panic(err) 130 | } 131 | } 132 | ``` 133 | 134 | ### Fancy UI using BubbleTea 135 | 136 | This example shows how to use **go-ytdlp** and [BubbleTea](https://github.com/charmbracelet/bubbletea) 137 | to create a fancy, though relatively simple, UI for downloading videos. 138 | 139 | ![BubbleTea demo](./_examples/bubble-dl/demo.gif) 140 | 141 | Source: [bubble-dl](./_examples/bubble-dl) 142 | 143 | --- 144 | 145 | ## :package: Install Function(s) & Binary Management 146 | 147 | The `Install*` function helpers in **go-ytdlp** allow you to automatically download and cache the 148 | required binaries (`yt-dlp`, `ffmpeg`, and `ffprobe`) for your platform. This makes it easy to get 149 | started without manually installing these dependencies, and ensures the correct versions are used. 150 | 151 | > **Note:** Download/installation of `ffmpeg` and `ffprobe` is only supported on a handful of platforms. 152 | > It is still recommended to install them via other means if your platform is not listed below. 153 | 154 | ### yt-dlp 155 | 156 | | OS/Arch | Download Source | 157 | |-----------------|----------------------------------| 158 | | darwin_amd64 | https://github.com/yt-dlp/yt-dlp | 159 | | darwin_arm64 | https://github.com/yt-dlp/yt-dlp | 160 | | linux_amd64 | https://github.com/yt-dlp/yt-dlp | 161 | | linux_arm64 | https://github.com/yt-dlp/yt-dlp | 162 | | linux_armv7l | https://github.com/yt-dlp/yt-dlp | 163 | | windows_amd64 | https://github.com/yt-dlp/yt-dlp | 164 | 165 | ### ffmpeg & ffprobe 166 | 167 | | OS/Arch | ffmpeg/ffprobe Download Source | 168 | |---------------|-----------------------------------------| 169 | | darwin_amd64 | https://evermeet.cx/ffmpeg/ | 170 | | linux_amd64 | https://github.com/yt-dlp/FFmpeg-Builds | 171 | | linux_arm64 | https://github.com/yt-dlp/FFmpeg-Builds | 172 | | windows_amd64 | https://github.com/yt-dlp/FFmpeg-Builds | 173 | | windows_arm | https://github.com/yt-dlp/FFmpeg-Builds | 174 | 175 | ## FlagConfig: JSON to/from Flags Conversion & Usage 176 | 177 | The `FlagConfig` type in **go-ytdlp** enables conversion between JSON and yt-dlp command-line flags. 178 | This is useful for scenarios such as HTTP APIs, web UIs, or persisting flag configurations in a database. 179 | 180 | - **Bidirectional Conversion:** Easily marshal and unmarshal yt-dlp flags to and from JSON. Use 181 | `Command.SetFlagConfig` and `Command.GetFlagConfig` to set/get the flag config. 182 | - **Validation:** Use the provided validation functions and JSON schema to ensure correctness. The 183 | JSON body allows duplicate flags (unless using the provided json schema), so always validate before use. 184 | - **JSON Schema:** The schema (available via the `optiondata.JSONSchema` variable and also 185 | [located here](./optiondata/json-schema.json)) can be used for type generation in other languages (e.g. 186 | TypeScript) and for client-side validation (e.g. using something like [json-schema-to-zod](https://www.npmjs.com/package/json-schema-to-zod) 187 | when working with a web UI). 188 | - **Persistence:** If storing flag configs in a database, note that yt-dlp flags can change or be removed 189 | at any time (in correlation to updates of **go-ytdlp**). Always validate after loading from storage. 190 | - If validation fails, clear the invalid values in the JSON before retrying (e.g. using the 191 | `ErrMultipleJSONParsingFlags` and `ErrJSONParsingFlag` error types, which include the path in the 192 | JSON where the issue occurred). 193 | - **SupportedExtractors:** If persisting the values from this generated type, remember that extractors 194 | can be changed or removed by yt-dlp at any time (in correlation to updates of **go-ytdlp**). If a 195 | user requests a retry and the extractor is missing, consider defaulting to `generic` or another fallback. 196 | - **Intended Usage:** `FlagConfig` is designed for JSON marshalling/unmarshalling only. It is not intended 197 | for direct use in Go code unless you are building HTTP servers, persisting configs, or similar use cases. 198 | The builder pattern should be used in all other cases. 199 | 200 | ### Example: HTTP Server which Invokes `go-ytdlp` 201 | 202 | You can find an example of how to use the `FlagConfig` type for HTTP server integration in the 203 | [`_examples/http-server`](./_examples/http-server) directory. 204 | 205 | --- 206 | 207 | 208 | 209 | ## :raising_hand_man: Support & Assistance 210 | 211 | * :heart: Please review the [Code of Conduct](.github/CODE_OF_CONDUCT.md) for 212 | guidelines on ensuring everyone has the best experience interacting with 213 | the community. 214 | * :raising_hand_man: Take a look at the [support](.github/SUPPORT.md) document on 215 | guidelines for tips on how to ask the right questions. 216 | * :lady_beetle: For all features/bugs/issues/questions/etc, [head over here](https://github.com/lrstanley/go-ytdlp/issues/new/choose). 217 | 218 | 219 | 220 | 221 | ## :handshake: Contributing 222 | 223 | * :heart: Please review the [Code of Conduct](.github/CODE_OF_CONDUCT.md) for guidelines 224 | on ensuring everyone has the best experience interacting with the 225 | community. 226 | * :clipboard: Please review the [contributing](.github/CONTRIBUTING.md) doc for submitting 227 | issues/a guide on submitting pull requests and helping out. 228 | * :old_key: For anything security related, please review this repositories [security policy](https://github.com/lrstanley/go-ytdlp/security/policy). 229 | 230 | 231 | 232 | 233 | ## :balance_scale: License 234 | 235 | ``` 236 | MIT License 237 | 238 | Copyright (c) 2023 Liam Stanley 239 | 240 | Permission is hereby granted, free of charge, to any person obtaining a copy 241 | of this software and associated documentation files (the "Software"), to deal 242 | in the Software without restriction, including without limitation the rights 243 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 244 | copies of the Software, and to permit persons to whom the Software is 245 | furnished to do so, subject to the following conditions: 246 | 247 | The above copyright notice and this permission notice shall be included in all 248 | copies or substantial portions of the Software. 249 | 250 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 251 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 252 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 253 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 254 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 255 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 256 | SOFTWARE. 257 | ``` 258 | 259 | _Also located [here](LICENSE)_ 260 | 261 | --------------------------------------------------------------------------------