Compare commits

...

5 Commits
v1.2.2 ... main

10 changed files with 128 additions and 238 deletions

View File

@ -25,7 +25,7 @@ You can configure the application using environment variables
| ---------------------------------- | ----------------------------------------------------------------------------------------------- | --------------- | ------------------------------------------------------------------------------ | | ---------------------------------- | ----------------------------------------------------------------------------------------------- | --------------- | ------------------------------------------------------------------------------ |
| YTDL_CONFIGDIR | Add a custom config directory to search for the config file | ` ` | | | YTDL_CONFIGDIR | Add a custom config directory to search for the config file | ` ` | |
| YTDL_ENV | The application environment | `Production` | `Development`, `Staging`, `Production` | | YTDL_ENV | The application environment | `Production` | `Development`, `Staging`, `Production` |
| YTDL_BINARYPATH | The path to the yt-dlp binary | `yt-dlp` | | | YTDL_YTDLP_BINARYPATH | The path to the yt-dlp binary | `yt-dlp` | |
| YTDL_HTTP_PORT | The tcp port for the web server to listen on | `8080` | | | YTDL_HTTP_PORT | The tcp port for the web server to listen on | `8080` | |
| YTDL_HTTP_LISTEN | The address for the web server to listen on | `127.0.0.1` | `0.0.0.0`, `127.0.0.1`, etc. | | YTDL_HTTP_LISTEN | The address for the web server to listen on | `127.0.0.1` | `0.0.0.0`, `127.0.0.1`, etc. |
| YTDL_HTTP_BASEPATH | The base path of the application, useful for reverse proxies | `/` | | | YTDL_HTTP_BASEPATH | The base path of the application, useful for reverse proxies | `/` | |

View File

@ -5,7 +5,6 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"sync"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/spf13/viper" "github.com/spf13/viper"
@ -109,6 +108,10 @@ func (c *DownloadController) ListDownloadLinks(w http.ResponseWriter, r *http.Re
}, layout...) }, layout...)
} }
var (
BUF_LEN = 1024
)
func (c *DownloadController) ProxyDownload(w http.ResponseWriter, r *http.Request) { func (c *DownloadController) ProxyDownload(w http.ResponseWriter, r *http.Request) {
videoUrl, ok := c.getUrlParam(r) videoUrl, ok := c.getUrlParam(r)
if !ok { if !ok {
@ -159,33 +162,19 @@ func (c *DownloadController) ProxyDownload(w http.ResponseWriter, r *http.Reques
index = -1 index = -1
} }
pread, pwrite := io.Pipe() read, write := io.Pipe()
var wg sync.WaitGroup
wg.Add(2)
go func() { go func() {
defer wg.Done() _, err := io.Copy(w, read)
if err := c.ytdl.Download(pwrite, videoUrl, format.FormatID, index); err != nil {
slog.Error("Failed to download", slog.String("error", err.Error()))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
pwrite.CloseWithError(err)
return
} else {
pwrite.Close()
}
}()
go func() {
defer wg.Done()
_, err = io.Copy(w, pread)
if err != nil { if err != nil {
slog.Error("Failed to copy", slog.String("error", err.Error())) slog.Error("Failed to copy", slog.String("error", err.Error()))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
} }
}() }()
wg.Wait() if err := c.ytdl.Download(write, videoUrl, format.FormatID, index); err != nil {
slog.Error("Failed to download", slog.String("error", err.Error()))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
write.Close()
} }

View File

@ -15,7 +15,7 @@
{{end}} {{end}}
<div class="d-flex flex-column flex-lg-row justify-content-center gap-5 mt-5"> <div class="d-flex flex-column flex-lg-row justify-content-center gap-5 mt-5">
<div class="d-flex justify-content-center"> <div class="d-flex justify-content-center">
<img src="{{.Meta.Thumbnail}}" alt="{{.Meta.Title}}" style="max-height: 25rem; max-width: 100%; margin: 0 auto" /> <img src="{{$video.Meta.Thumbnail}}" alt="{{$video.Meta.Title}}" style="max-height: 25rem; max-width: 100%; margin: 0 auto" />
</div> </div>
<div class="downloads flex-lg-grow-1"> <div class="downloads flex-lg-grow-1">
{{range $index, $format := $video.Formats}} {{range $index, $format := $video.Formats}}
@ -25,12 +25,18 @@
</div> </div>
{{if gt (len $video.OtherFormats) 0}} {{if gt (len $video.OtherFormats) 0}}
<div class="d-grid my-3"> <div class="d-grid my-3">
<button class="btn btn-secondary" data-bs-toggle="collapse" <button
data-bs-target="#{{$root.Meta.ID}}-{{$vidIndex}}-collapse"> type="button"
class="btn btn-secondary"
data-bs-toggle="collapse"
data-bs-target="#collapse-{{$root.Meta.ID}}-{{$vidIndex}}"
aria-expanded="false"
aria-controls="collapse-{{$root.Meta.ID}}-{{$vidIndex}}"
>
See More Formats See More Formats
</button> </button>
</div> </div>
<div id="{{$root.Meta.ID}}-{{$vidIndex}}-collapse" class="collapse"> <div id="collapse-{{$root.Meta.ID}}-{{$vidIndex}}" class="collapse">
<div class="downloads d-flex d-md-grid flex-column"> <div class="downloads d-flex d-md-grid flex-column">
{{range $index, $format := $video.OtherFormats}} {{range $index, $format := $video.OtherFormats}}
{{$label := sprintf "ext: %s, resolution: %s, filesize: %s, note: %s" $format.Ext $format.Resolution {{$label := sprintf "ext: %s, resolution: %s, filesize: %s, note: %s" $format.Ext $format.Resolution

View File

@ -2,6 +2,7 @@ package config
import ( import (
"os" "os"
"path"
"strings" "strings"
"time" "time"
@ -10,11 +11,11 @@ import (
) )
type Config struct { type Config struct {
Env string `mapstructure:"env"` Env string `mapstructure:"env"`
BinaryPath string `mapstructure:"binaryPath"` Ytdlp ConfigYtdlp `mapstructure:"ytdlp"`
HTTP ConfigHTTP `mapstructure:"http"` HTTP ConfigHTTP `mapstructure:"http"`
Cache ConfigCache `mapstructure:"cache"` Cache ConfigCache `mapstructure:"cache"`
Cookies ConfigCookies `mapstructure:"cookies"` Cookies ConfigCookies `mapstructure:"cookies"`
} }
func (c *Config) IsProduction() bool { func (c *Config) IsProduction() bool {
@ -29,6 +30,10 @@ func (c *Config) IsStaging() bool {
return c.Env == "Staging" return c.Env == "Staging"
} }
type ConfigYtdlp struct {
BinaryPath string `mapstructure:"binaryPath"`
}
type ConfigHTTP struct { type ConfigHTTP struct {
Port int `mapstructure:"port"` Port int `mapstructure:"port"`
Listen string `mapstructure:"listen"` Listen string `mapstructure:"listen"`
@ -56,8 +61,8 @@ type ConfigCookiesFromBrowser struct {
func DefaultConfig() *Config { func DefaultConfig() *Config {
return &Config{ return &Config{
Env: "Production", Env: "Production",
BinaryPath: "yt-dlp", Ytdlp: ConfigYtdlp{BinaryPath: "yt-dlp"},
HTTP: ConfigHTTP{ HTTP: ConfigHTTP{
Port: 8080, Port: 8080,
Listen: "127.0.0.1", Listen: "127.0.0.1",
@ -76,46 +81,44 @@ func DefaultConfig() *Config {
} }
func LoadConfig(paths ...string) (*Config, error) { func LoadConfig(paths ...string) (*Config, error) {
v := viper.New() viper.SetEnvPrefix("YTDL")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
v.SetEnvPrefix("YTDL") viper.SetConfigName("config")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) viper.SetConfigType("yaml")
v.AutomaticEnv()
v.SetConfigName("config")
v.SetConfigType("yaml")
if len(paths) > 0 { if len(paths) > 0 {
for _, path := range paths { for _, p := range paths {
v.AddConfigPath(path) viper.AddConfigPath(path.Dir(p))
} }
} else { } else {
envDir := os.Getenv("YTDL_CONFIGDIR") envDir := os.Getenv("YTDL_CONFIGDIR")
if envDir != "" { if envDir != "" {
v.AddConfigPath(envDir) viper.AddConfigPath(envDir)
} }
v.AddConfigPath(".") viper.AddConfigPath(".")
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()
if err == nil { if err == nil {
v.AddConfigPath(homeDir + "/.config/ytdl-web") viper.AddConfigPath(homeDir + "/.config/ytdl-web")
} }
v.AddConfigPath(xdg.ConfigHome + "/ytdl-web") viper.AddConfigPath(xdg.ConfigHome + "/ytdl-web")
for _, dir := range xdg.ConfigDirs { for _, dir := range xdg.ConfigDirs {
v.AddConfigPath(dir + "/ytdl-web") viper.AddConfigPath(dir + "/ytdl-web")
} }
} }
if err := v.ReadInConfig(); err != nil { if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok { if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, err return nil, err
} }
} }
config := DefaultConfig() config := DefaultConfig()
if err := v.Unmarshal(config); err != nil { if err := viper.Unmarshal(config); err != nil {
return nil, err return nil, err
} }

2
go.mod
View File

@ -1,6 +1,6 @@
module go.fifitido.net/ytdl-web module go.fifitido.net/ytdl-web
go 1.21 go 1.22
require ( require (
github.com/adrg/xdg v0.4.0 github.com/adrg/xdg v0.4.0

View File

@ -4,7 +4,7 @@
# https://devenv.sh/basics/ # https://devenv.sh/basics/
env.NAME = "ytdl-web"; env.NAME = "ytdl-web";
env.BINARY_OUT = "./out/ytdl-web"; env.BINARY_OUT = "./out/ytdl-web";
env.VERSION = "v1.2.2"; env.VERSION = "v1.2.3";
env.VERSION_PKG = "go.fifitido.net/ytdl-web/version"; env.VERSION_PKG = "go.fifitido.net/ytdl-web/version";
env.DOCKER_REGISTRY = "git.fifitido.net"; env.DOCKER_REGISTRY = "git.fifitido.net";
env.DOCKER_ORG = "apps"; env.DOCKER_ORG = "apps";

View File

@ -1,56 +0,0 @@
package ytdl
import (
"bytes"
"encoding/json"
"os/exec"
"go.fifitido.net/ytdl-web/pkg/ytdl/metadata"
)
func Exec(binary, url string, options ...Option) error {
opts := Options{
args: []string{url},
stdin: nil,
stdout: nil,
stderr: nil,
output: nil,
}
for _, opt := range options {
if err := opt(&opts); err != nil {
return err
}
}
cmd := exec.Command(binary, opts.args...)
if opts.stdin != nil {
cmd.Stdin = opts.stdin
}
if opts.stdout != nil {
cmd.Stdout = opts.stdout
}
var stderr = new(bytes.Buffer)
if opts.stderr != nil {
cmd.Stderr = stderr
}
if err := cmd.Run(); err != nil {
return &Error{
stderr: stderr.String(),
child: err,
}
}
buf, bufOk := opts.stdout.(*bytes.Buffer)
meta, metaOk := opts.output.(*metadata.Metadata)
if bufOk && metaOk {
if err := json.Unmarshal(buf.Bytes(), meta); err != nil {
return err
}
}
return nil
}

View File

@ -1,99 +0,0 @@
package ytdl
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
"go.fifitido.net/ytdl-web/pkg/ytdl/metadata"
)
type Options struct {
args []string
stdin io.Reader
stdout io.Writer
stderr io.Writer
output interface{}
}
type Option func(*Options) error
func WithFormat(format string) Option {
return func(opts *Options) error {
opts.args = append(opts.args, "--format", format)
return nil
}
}
func WithCookieFile(cookieFile string) Option {
return func(opts *Options) error {
opts.args = append(opts.args, "--cookies", cookieFile)
return nil
}
}
func WithDumpJson(meta *metadata.Metadata) Option {
return func(opts *Options) error {
opts.args = append(opts.args, "--dump-single-json")
opts.stdout = new(bytes.Buffer)
opts.output = meta
return nil
}
}
func WithLoadJson(metadata *metadata.Metadata) Option {
return func(opts *Options) error {
opts.args = append(opts.args, "--load-info-json", "-")
json, err := json.Marshal(metadata)
if err != nil {
return err
}
opts.stdin = bytes.NewBuffer(json)
return nil
}
}
func WithBrowserCookies(browser, keyring, profile, container string) Option {
return func(opts *Options) error {
var sb strings.Builder
sb.WriteString(browser)
if keyring != "" {
sb.WriteByte('+')
sb.WriteString(keyring)
}
if profile != "" {
sb.WriteByte(':')
sb.WriteString(profile)
}
if container != "" {
sb.WriteByte(':')
sb.WriteByte(':')
sb.WriteString(container)
}
opts.args = append(opts.args, "--cookies-from-browser", sb.String())
return nil
}
}
func WithStreamOutput(output io.Writer) Option {
return func(opts *Options) error {
opts.args = append(opts.args, "--output", "-")
opts.stdout = output
return nil
}
}
func WithPlaylistIndex(index int) Option {
return func(opts *Options) error {
opts.args = append(opts.args, "--playlist-items", fmt.Sprint(index+1))
return nil
}
}

View File

@ -1,6 +1,7 @@
package ytdl package ytdl
type Error struct { type Error struct {
stdout string
stderr string stderr string
child error child error
} }
@ -9,6 +10,10 @@ func (e *Error) Error() string {
return e.child.Error() return e.child.Error()
} }
func (e *Error) Stdout() string {
return e.stdout
}
func (e *Error) Stderr() string { func (e *Error) Stderr() string {
return e.stderr return e.stderr
} }

View File

@ -2,7 +2,8 @@ package ytdl
import ( import (
"bytes" "bytes"
"errors" "encoding/json"
"fmt"
"io" "io"
"os/exec" "os/exec"
"strings" "strings"
@ -28,7 +29,7 @@ type ytdlImpl struct {
func NewYtdl(cfg *config.Config, logger *slog.Logger, cache cache.MetadataCache) Ytdl { func NewYtdl(cfg *config.Config, logger *slog.Logger, cache cache.MetadataCache) Ytdl {
cmd := exec.Command( cmd := exec.Command(
cfg.BinaryPath, cfg.Ytdlp.BinaryPath,
"--version", "--version",
) )
var out bytes.Buffer var out bytes.Buffer
@ -43,28 +44,44 @@ func NewYtdl(cfg *config.Config, logger *slog.Logger, cache cache.MetadataCache)
} }
} }
func (y *ytdlImpl) baseOptions(url string) []Option { func buildBrowserCookieString(browser, keyring, profile, container string) string {
options := []Option{} var sb strings.Builder
sb.WriteString(browser)
metadata, err := y.cache.Get(url) if keyring != "" {
if err == nil { sb.WriteByte('+')
options = append(options, WithLoadJson(metadata)) sb.WriteString(keyring)
} }
if profile != "" {
sb.WriteByte(':')
sb.WriteString(profile)
}
if container != "" {
sb.WriteByte(':')
sb.WriteByte(':')
sb.WriteString(container)
}
return sb.String()
}
func (y *ytdlImpl) appendCookieArgs(args []string) []string {
if y.cfg.Cookies.Enabled { if y.cfg.Cookies.Enabled {
if y.cfg.Cookies.FromBrowser.Browser != "" { if y.cfg.Cookies.FromBrowser.Browser != "" {
options = append(options, WithBrowserCookies( args = append(args, "--cookies-from-browser", buildBrowserCookieString(
y.cfg.Cookies.FromBrowser.Browser, y.cfg.Cookies.FromBrowser.Browser,
y.cfg.Cookies.FromBrowser.Keyring, y.cfg.Cookies.FromBrowser.Keyring,
y.cfg.Cookies.FromBrowser.Profile, y.cfg.Cookies.FromBrowser.Profile,
y.cfg.Cookies.FromBrowser.Container, y.cfg.Cookies.FromBrowser.Container,
)) ))
} else { } else {
options = append(options, WithCookieFile(y.cfg.Cookies.FilePath)) args = append(args, "--cookies", y.cfg.Cookies.FilePath)
} }
} }
return options return args
} }
func (y *ytdlImpl) Version() string { func (y *ytdlImpl) Version() string {
@ -78,27 +95,32 @@ func (y *ytdlImpl) GetMetadata(url string) (*metadata.Metadata, error) {
return meta, nil return meta, nil
} }
meta = &metadata.Metadata{} args := []string{
options := append( url,
y.baseOptions(url), "--dump-single-json",
WithDumpJson(meta), }
)
if err := Exec(y.cfg.BinaryPath, url, options...); err != nil { args = y.appendCookieArgs(args)
cmd := exec.Command(y.cfg.Ytdlp.BinaryPath, args...)
out, err := cmd.Output()
if err != nil {
attrs := []any{ attrs := []any{
slog.String("url", url), slog.String("url", url),
slog.String("error", err.Error()), slog.String("error", err.Error()),
} }
var ytdlErr *Error
if ok := errors.As(err, &ytdlErr); ok {
attrs = append(attrs, slog.String("stderr", ytdlErr.Stderr()))
}
y.logger.Error("failed to get metadata", attrs...) y.logger.Error("failed to get metadata", attrs...)
return nil, err return nil, err
} }
meta = &metadata.Metadata{}
if err := json.Unmarshal(out, meta); err != nil {
y.logger.Error("failed to unmarshal metadata", slog.String("url", url), slog.String("error", err.Error()))
return nil, err
}
if err := y.cache.Set(url, meta, y.cfg.Cache.TTL); err != nil { if err := y.cache.Set(url, meta, y.cfg.Cache.TTL); err != nil {
y.logger.Warn("failed to cache metadata", slog.String("url", url), slog.String("error", err.Error())) y.logger.Warn("failed to cache metadata", slog.String("url", url), slog.String("error", err.Error()))
} }
@ -108,17 +130,37 @@ func (y *ytdlImpl) GetMetadata(url string) (*metadata.Metadata, error) {
// Download implements Ytdl // Download implements Ytdl
func (y *ytdlImpl) Download(w io.Writer, url, format string, index int) error { func (y *ytdlImpl) Download(w io.Writer, url, format string, index int) error {
options := append( args := []string{
y.baseOptions(url), url,
WithFormat(format), "--format", format,
WithStreamOutput(w), "--output", "-",
)
if index >= 0 {
options = append(options, WithPlaylistIndex(index))
} }
if err := Exec(y.cfg.BinaryPath, url, options...); err != nil { if index >= 0 {
args = append(args, "--playlist-ites", fmt.Sprint(index+1))
}
args = y.appendCookieArgs(args)
metadata, err := y.cache.Get(url)
if err == nil {
args = append(args, "--load-info-json", "-")
}
cmd := exec.Command(y.cfg.Ytdlp.BinaryPath, args...)
cmd.Stdout = w
if err == nil {
json, err := json.Marshal(metadata)
if err != nil {
return err
}
cmd.Stdin = bytes.NewReader(json)
}
if err := cmd.Run(); err != nil {
y.logger.Error("failed to download", slog.String("url", url), slog.String("error", err.Error()))
return err return err
} }