Import: Add ytdl package for downloading videos from URLs #4982

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-05-02 10:04:34 +02:00
parent a7d927a83b
commit 8faf3fc918
31 changed files with 1520 additions and 47 deletions

6
go.mod
View File

@@ -14,7 +14,7 @@ require (
github.com/esimov/pigo v1.4.6
github.com/gin-contrib/gzip v1.2.3
github.com/gin-gonic/gin v1.10.0
github.com/golang/geo v0.0.0-20250425181934-8bbaf6e6a91f
github.com/golang/geo v0.0.0-20250501180049-e7dab2dbd967
github.com/google/open-location-code/go v0.0.0-20250415120251-fa6d7f9d4765
github.com/gorilla/websocket v1.5.3
github.com/gosimple/slug v1.15.0
@@ -86,7 +86,7 @@ require (
github.com/ugjka/go-tz/v2 v2.2.6
github.com/urfave/cli/v2 v2.27.6
github.com/wamuir/graft v0.10.0
github.com/zitadel/oidc/v3 v3.37.0
github.com/zitadel/oidc/v3 v3.38.1
golang.org/x/mod v0.24.0
golang.org/x/sys v0.32.0
)
@@ -148,7 +148,7 @@ require (
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/oauth2 v0.28.0 // indirect
golang.org/x/oauth2 v0.29.0 // indirect
golang.org/x/tools v0.31.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

12
go.sum
View File

@@ -182,8 +182,8 @@ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGw
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20250425181934-8bbaf6e6a91f h1:YEt1r/CDCLvHcUhsTRor2QXPlDUqZLox9Tu7/XkYaPc=
github.com/golang/geo v0.0.0-20250425181934-8bbaf6e6a91f/go.mod h1:DaoiJOlOKPwoy8qrxeMEsEFBPB1P+vCktZlA+qARoWg=
github.com/golang/geo v0.0.0-20250501180049-e7dab2dbd967 h1:89kNX1y32rD1En7XlumVQoJgRLqhP/LAvhSnfYsk/nw=
github.com/golang/geo v0.0.0-20250501180049-e7dab2dbd967/go.mod h1:DaoiJOlOKPwoy8qrxeMEsEFBPB1P+vCktZlA+qARoWg=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -412,8 +412,8 @@ github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBi
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zitadel/logging v0.6.2 h1:MW2kDDR0ieQynPZ0KIZPrh9ote2WkxfBif5QoARDQcU=
github.com/zitadel/logging v0.6.2/go.mod h1:z6VWLWUkJpnNVDSLzrPSQSQyttysKZ6bCRongw0ROK4=
github.com/zitadel/oidc/v3 v3.37.0 h1:nYATWlnP7f18XiAbw6upUruBaqfB1kUrXrSTf1EYGO8=
github.com/zitadel/oidc/v3 v3.37.0/go.mod h1:/xDan4OUQhguJ4Ur73OOJrtugvR164OMnidXP9xfVNw=
github.com/zitadel/oidc/v3 v3.38.1 h1:VTf1Bv/33UbSwJnIWbfEIdpUGYKfoHetuBNIqVTcjvA=
github.com/zitadel/oidc/v3 v3.38.1/go.mod h1:muukzAasaWmn3vBwEVMglJfuTE0PKCvLJGombPwXIRw=
github.com/zitadel/schema v1.3.1 h1:QT3kwiRIRXXLVAs6gCK/u044WmUVh6IlbLXUsn6yRQU=
github.com/zitadel/schema v1.3.1/go.mod h1:071u7D2LQacy1HAN+YnMd/mx1qVE2isb0Mjeqg46xnU=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
@@ -523,8 +523,8 @@ golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4Iltr
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

View File

@@ -2,13 +2,14 @@ package api
import (
"encoding/json"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
)
func TestCreateAlbumLink(t *testing.T) {

View File

@@ -2,11 +2,11 @@ package api
import (
"encoding/json"
"github.com/tidwall/gjson"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/form"

View File

@@ -58,11 +58,11 @@ func ZipCreate(router *gin.RouterGroup) {
// Configure file selection based on user settings.
var selection query.FileSelection
if dl := conf.Settings().Download; dl.Disabled {
if settings := conf.Settings().Download; settings.Disabled {
AbortFeatureDisabled(c)
return
} else {
selection = query.DownloadSelection(dl.MediaRaw, dl.MediaSidecar, dl.Originals)
selection = query.DownloadSelection(settings.MediaRaw, settings.MediaSidecar, settings.Originals)
}
// Find files to download.

View File

@@ -19,21 +19,21 @@ func TestZip(t *testing.T) {
assert.Contains(t, message.String(), "Zip created")
assert.Equal(t, http.StatusOK, r.Code)
filename := gjson.Get(r.Body.String(), "filename")
dl := PerformRequest(app, "GET", "/api/v1/zip/"+filename.String()+"?t="+conf.DownloadToken())
assert.Equal(t, http.StatusOK, dl.Code)
response := PerformRequest(app, "GET", "/api/v1/zip/"+filename.String()+"?t="+conf.DownloadToken())
assert.Equal(t, http.StatusOK, response.Code)
})
t.Run("ErrNoItemsSelected", func(t *testing.T) {
r := PerformRequestWithBody(app, "POST", "/api/v1/zip", `{"photos": []}`)
val := gjson.Get(r.Body.String(), "error")
response := PerformRequestWithBody(app, "POST", "/api/v1/zip", `{"photos": []}`)
val := gjson.Get(response.Body.String(), "error")
assert.Equal(t, "No items selected", val.String())
assert.Equal(t, http.StatusBadRequest, r.Code)
assert.Equal(t, http.StatusBadRequest, response.Code)
})
t.Run("ErrBadRequest", func(t *testing.T) {
r := PerformRequestWithBody(app, "POST", "/api/v1/zip", `{"photos": [123, "ps6sg6be2lvl0yxx"]}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
response := PerformRequestWithBody(app, "POST", "/api/v1/zip", `{"photos": [123, "ps6sg6be2lvl0yxx"]}`)
assert.Equal(t, http.StatusBadRequest, response.Code)
})
t.Run("ErrNotFound", func(t *testing.T) {
r := PerformRequest(app, "GET", "/api/v1/zip/xxx?t="+conf.DownloadToken())
assert.Equal(t, http.StatusNotFound, r.Code)
response := PerformRequest(app, "GET", "/api/v1/zip/xxx?t="+conf.DownloadToken())
assert.Equal(t, http.StatusNotFound, response.Code)
})
}

View File

@@ -11,7 +11,7 @@ import (
// FFmpegBin returns the ffmpeg executable file name.
func (c *Config) FFmpegBin() string {
return findBin(c.options.FFmpegBin, encode.FFmpegBin)
return FindBin(c.options.FFmpegBin, encode.FFmpegBin)
}
// FFmpegEnabled checks if FFmpeg is enabled for video transcoding.

View File

@@ -11,7 +11,7 @@ func (c *Config) VectorEnabled() bool {
// RsvgConvertBin returns the rsvg-convert executable file name.
func (c *Config) RsvgConvertBin() string {
return findBin(c.options.RsvgConvertBin, "rsvg-convert")
return FindBin(c.options.RsvgConvertBin, "rsvg-convert")
}
// RsvgConvertEnabled checks if rsvg-convert is enabled for SVG conversion.
@@ -21,7 +21,7 @@ func (c *Config) RsvgConvertEnabled() bool {
// ImageMagickBin returns the ImageMagick "convert" executable file name.
func (c *Config) ImageMagickBin() string {
return findBin(c.options.ImageMagickBin, "convert", "magick")
return FindBin(c.options.ImageMagickBin, "convert", "magick")
}
// ImageMagickExclude returns the file extensions not to be used with ImageMagick.
@@ -36,7 +36,7 @@ func (c *Config) ImageMagickEnabled() bool {
// JpegXLDecoderBin returns the JPEG XL decoder executable file name.
func (c *Config) JpegXLDecoderBin() string {
return findBin("", "djxl")
return FindBin("", "djxl")
}
// JpegXLEnabled checks if JPEG XL file format support is enabled.
@@ -58,7 +58,7 @@ func (c *Config) DisableJpegXL() bool {
// HeifConvertBin returns the name of the "heif-dec" executable ("heif-convert" in earlier libheif versions).
// see https://github.com/photoprism/photoprism/issues/4439
func (c *Config) HeifConvertBin() string {
return findBin(c.options.HeifConvertBin, "heif-dec", "heif-convert")
return FindBin(c.options.HeifConvertBin, "heif-dec", "heif-convert")
}
// HeifConvertOrientation returns the Exif orientation of images generated with libheif (keep, reset).
@@ -78,7 +78,7 @@ func (c *Config) SipsEnabled() bool {
// SipsBin returns the SIPS executable file name.
func (c *Config) SipsBin() string {
return findBin(c.options.SipsBin, "sips")
return FindBin(c.options.SipsBin, "sips")
}
// SipsExclude returns the file extensions no not be used with Sips.

View File

@@ -16,7 +16,7 @@ func (c *Config) RawPresets() bool {
// DarktableBin returns the darktable-cli executable file name.
func (c *Config) DarktableBin() string {
return findBin(c.options.DarktableBin, "darktable-cli")
return FindBin(c.options.DarktableBin, "darktable-cli")
}
// DarktableExclude returns the file extensions no not be used with Darktable.
@@ -71,7 +71,7 @@ func (c *Config) DarktableEnabled() bool {
// RawTherapeeBin returns the rawtherapee-cli executable file name.
func (c *Config) RawTherapeeBin() string {
return findBin(c.options.RawTherapeeBin, "rawtherapee-cli")
return FindBin(c.options.RawTherapeeBin, "rawtherapee-cli")
}
// RawTherapeeExclude returns the file extensions no not be used with RawTherapee.

View File

@@ -7,7 +7,7 @@ func (c *Config) ExifBruteForce() bool {
// ExifToolBin returns the exiftool executable file name.
func (c *Config) ExifToolBin() string {
return findBin(c.options.ExifToolBin, "exiftool")
return FindBin(c.options.ExifToolBin, "exiftool")
}
// ExifToolJson checks if creating JSON metadata sidecar files with Exiftool is enabled.

View File

@@ -22,8 +22,8 @@ var (
tempPath = ""
)
// findBin resolves the absolute file path of external binaries.
func findBin(configBin string, defaultBin ...string) (binPath string) {
// FindBin resolves the absolute file path of external binaries.
func FindBin(configBin string, defaultBin ...string) (binPath string) {
// Binary file paths to be checked.
var search []string
@@ -655,17 +655,17 @@ func (c *Config) TestdataPath() string {
// MariadbBin returns the mariadb executable file name.
func (c *Config) MariadbBin() string {
return findBin("", "mariadb", "mysql")
return FindBin("", "mariadb", "mysql")
}
// MariadbDumpBin returns the mariadb-dump executable file name.
func (c *Config) MariadbDumpBin() string {
return findBin("", "mariadb-dump", "mysqldump")
return FindBin("", "mariadb-dump", "mysqldump")
}
// SqliteBin returns the sqlite executable file name.
func (c *Config) SqliteBin() string {
return findBin("", "sqlite3")
return FindBin("", "sqlite3")
}
// OriginalsAlbumsPath returns the optional album YAML file path inside originals.

View File

@@ -9,15 +9,15 @@ import (
"github.com/photoprism/photoprism/pkg/rnd"
)
func TestConfig_findBin(t *testing.T) {
assert.Equal(t, "", findBin("yyy123", "xxx123"))
assert.Equal(t, "", findBin("yyy123", "sh"))
assert.Equal(t, "/usr/bin/sh", findBin("sh", "yyy123"))
assert.Equal(t, "/usr/bin/sh", findBin("", "sh"))
assert.Equal(t, "/usr/bin/sh", findBin("", "", "sh"))
assert.Equal(t, "/usr/bin/sh", findBin("", "yyy123", "sh"))
assert.Equal(t, "/usr/bin/sh", findBin("sh", "bash"))
assert.Equal(t, "/usr/bin/bash", findBin("bash", "sh"))
func TestConfig_FindBin(t *testing.T) {
assert.Equal(t, "", FindBin("yyy123", "xxx123"))
assert.Equal(t, "", FindBin("yyy123", "sh"))
assert.Equal(t, "/usr/bin/sh", FindBin("sh", "yyy123"))
assert.Equal(t, "/usr/bin/sh", FindBin("", "sh"))
assert.Equal(t, "/usr/bin/sh", FindBin("", "", "sh"))
assert.Equal(t, "/usr/bin/sh", FindBin("", "yyy123", "sh"))
assert.Equal(t, "/usr/bin/sh", FindBin("sh", "bash"))
assert.Equal(t, "/usr/bin/bash", FindBin("bash", "sh"))
}
func TestConfig_SidecarPath(t *testing.T) {

View File

@@ -1,7 +1,10 @@
package encode
// FFmpegBin defines the default ffmpeg binary name.
const FFmpegBin = "ffmpeg"
const (
FFmpegBin = "ffmpeg"
FFprobeBin = "ffprobe"
)
// Bitrate limit min, max, and default settings in MBps.
const (

View File

@@ -0,0 +1,39 @@
package ytdl
import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
)
var (
Bin = ""
FFmpegBin = ""
FFprobeBin = ""
)
// FindBin returns the YouTube / M38U video downloader binary name.
func FindBin() string {
if Bin == "" {
Bin = config.FindBin("yt-dlp", "yt-dl", "youtube-dl", "dl")
}
return Bin
}
// FindFFmpegBin returns the "ffmpeg" command binary name.
func FindFFmpegBin() string {
if FFmpegBin == "" {
FFmpegBin = config.FindBin(encode.FFmpegBin)
}
return FFmpegBin
}
// FindFFprobeBin returns the "ffprobe" command binary name.
func FindFFprobeBin() string {
if FFprobeBin == "" {
FFprobeBin = config.FindBin(encode.FFprobeBin)
}
return FFprobeBin
}

View File

@@ -0,0 +1,20 @@
package ytdl
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestFindBin(t *testing.T) {
assert.True(t, strings.Contains(FindBin(), "yt-dlp"), "binary filepath should contain 'yt-dlp'")
}
func TestFindFFmpegBin(t *testing.T) {
assert.True(t, strings.Contains(FindFFmpegBin(), "ffmpeg"), "binary filepath should contain 'ffmpeg'")
}
func TestFindFFprobeBin(t *testing.T) {
assert.True(t, strings.Contains(FindFFprobeBin(), "ffprobe"), "binary filepath should contain 'ffprobe'")
}

View File

@@ -0,0 +1,21 @@
package ytdl
import (
"context"
)
// Download downloads media from a URL using the specified options and filter (usually a format ID or quality flag).
// If filter is empty, then youtube-dl will use its default format selector.
func Download(
ctx context.Context,
rawURL string,
options Options,
filter string,
) (*DownloadResult, error) {
options.noInfoDownload = true
d, err := New(ctx, rawURL, options)
if err != nil {
return nil, err
}
return d.Download(ctx, filter)
}

View File

@@ -0,0 +1,82 @@
package ytdl
import (
"bytes"
"context"
"io"
"os/exec"
"strings"
"testing"
)
func TestDownload(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
stderrBuf := &bytes.Buffer{}
r, err := New(context.Background(), testVideoRawURL, Options{
StderrFn: func(cmd *exec.Cmd) io.Writer {
return stderrBuf
},
})
if err != nil {
t.Fatal(err)
}
dr, err := r.Download(context.Background(), r.Info.Formats[0].FormatID)
if err != nil {
t.Fatal(err)
}
downloadBuf := &bytes.Buffer{}
n, err := io.Copy(downloadBuf, dr)
if err != nil {
t.Fatal(err)
}
dr.Close()
if n != int64(downloadBuf.Len()) {
t.Errorf("copy n not equal to download buffer: %d!=%d", n, downloadBuf.Len())
}
if n < 10000 {
t.Errorf("should have copied at least 10000 bytes: %d", n)
}
if !strings.Contains(stderrBuf.String(), "Destination") {
t.Errorf("did not find expected log message on stderr: %q", stderrBuf.String())
}
}
func TestDownloadWithoutInfo(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
stderrBuf := &bytes.Buffer{}
dr, err := Download(context.Background(), testVideoRawURL, Options{
StderrFn: func(cmd *exec.Cmd) io.Writer {
return stderrBuf
},
}, "")
if err != nil {
t.Fatal(err)
}
downloadBuf := &bytes.Buffer{}
n, err := io.Copy(downloadBuf, dr)
if err != nil {
t.Fatal(err)
}
dr.Close()
if n != int64(downloadBuf.Len()) {
t.Errorf("copy n not equal to download buffer: %d!=%d", n, downloadBuf.Len())
}
if n < 10000 {
t.Errorf("should have copied at least 10000 bytes: %d", n)
}
if !strings.Contains(stderrBuf.String(), "Destination") {
t.Errorf("did not find expected log message on stderr: %q", stderrBuf.String())
}
}

View File

@@ -0,0 +1,18 @@
package ytdl
import (
"errors"
)
// YoutubedlError is a error from youtube-dl
type YoutubedlError string
func (e YoutubedlError) Error() string {
return string(e)
}
// ErrNotAPlaylist error when single entry when expected a playlist
var ErrNotAPlaylist = errors.New("single entry when expected a playlist")
// ErrNotASingleEntry error when playlist when expected a single entry
var ErrNotASingleEntry = errors.New("playlist when expected a single entry")

View File

@@ -0,0 +1,39 @@
package ytdl
import (
"fmt"
)
// Format youtube-dl downloadable format
type Format struct {
Ext string `json:"ext"` // Video filename extension
Format string `json:"format"` // A human-readable description of the format
FormatID string `json:"format_id"` // Format code specified by `--format`
FormatNote string `json:"format_note"` // Additional info about the format
Width float64 `json:"width"` // Width of the video
Height float64 `json:"height"` // Height of the video
Resolution string `json:"resolution"` // Textual description of width and height
TBR float64 `json:"tbr"` // Average bitrate of audio and video in KBit/s
ABR float64 `json:"abr"` // Average audio bitrate in KBit/s
ACodec string `json:"acodec"` // Name of the audio codec in use
ASR float64 `json:"asr"` // Audio sampling rate in Hertz
VBR float64 `json:"vbr"` // Average video bitrate in KBit/s
FPS float64 `json:"fps"` // Frame rate
VCodec string `json:"vcodec"` // Name of the video codec in use
Container string `json:"container"` // Name of the container format
Filesize float64 `json:"filesize"` // The number of bytes, if known in advance
FilesizeApprox float64 `json:"filesize_approx"` // An estimate for the number of bytes
Protocol string `json:"protocol"` // The protocol that will be used for the actual download
HTTPHeaders map[string]string `json:"http_headers"`
}
func (f Format) String() string {
return fmt.Sprintf("%s:%s:%s abr:%f vbr:%f tbr:%f",
f.FormatID,
f.Protocol,
f.Ext,
f.ABR,
f.VBR,
f.TBR,
)
}

View File

@@ -0,0 +1,314 @@
package ytdl
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
)
// Info youtube-dl info
type Info struct {
// Generated from youtube-dl README using:
// sed -e 's/ - `\(.*\)` (\(.*\)): \(.*\)/\1 \2 `json:"\1"` \/\/ \3/' | sed -e 's/numeric/float64/' | sed -e 's/boolean/bool/' | sed -e 's/_id/ID/' | sed -e 's/_count/Count/'| sed -e 's/_uploader/Uploader/' | sed -e 's/_key/Key/' | sed -e 's/_year/Year/' | sed -e 's/_title/Title/' | sed -e 's/_rating/Rating/' | sed -e 's/_number/Number/' | awk '{print toupper(substr($0, 0, 1)) substr($0, 2)}'
ID string `json:"id"` // Video identifier
Title string `json:"title"` // Video title
URL string `json:"url"` // Video URL
AltTitle string `json:"alt_title"` // A secondary title of the video
DisplayID string `json:"display_id"` // An alternative identifier for the video
Uploader string `json:"uploader"` // Full name of the video uploader
License string `json:"license"` // License name the video is licensed under
Creator string `json:"creator"` // The creator of the video
ReleaseDate string `json:"release_date"` // The date (YYYYMMDD) when the video was released
Timestamp float64 `json:"timestamp"` // UNIX timestamp of the moment the video became available
UploadDate string `json:"upload_date"` // Video upload date (YYYYMMDD)
UploaderID string `json:"uploader_id"` // Nickname or id of the video uploader
Channel string `json:"channel"` // Full name of the channel the video is uploaded on
ChannelID string `json:"channel_id"` // Id of the channel
Location string `json:"location"` // Physical location where the video was filmed
Duration float64 `json:"duration"` // Length of the video in seconds
ViewCount float64 `json:"view_count"` // How many users have watched the video on the platform
LikeCount float64 `json:"like_count"` // Number of positive ratings of the video
DislikeCount float64 `json:"dislike_count"` // Number of negative ratings of the video
RepostCount float64 `json:"repost_count"` // Number of reposts of the video
AverageRating float64 `json:"average_rating"` // Average rating give by users, the scale used depends on the webpage
CommentCount float64 `json:"comment_count"` // Number of comments on the video
AgeLimit float64 `json:"age_limit"` // Age restriction for the video (years)
IsLive bool `json:"is_live"` // Whether this video is a live stream or a fixed-length video
StartTime float64 `json:"start_time"` // Time in seconds where the reproduction should start, as specified in the URL
EndTime float64 `json:"end_time"` // Time in seconds where the reproduction should end, as specified in the URL
Extractor string `json:"extractor"` // Name of the extractor
ExtractorKey string `json:"extractor_key"` // Key name of the extractor
Epoch float64 `json:"epoch"` // Unix epoch when creating the file
Autonumber float64 `json:"autonumber"` // Five-digit number that will be increased with each download, starting at zero
Playlist string `json:"playlist"` // Name or id of the playlist that contains the video
PlaylistIndex float64 `json:"playlist_index"` // Index of the video in the playlist padded with leading zeros according to the total length of the playlist
PlaylistID string `json:"playlist_id"` // Playlist identifier
PlaylistTitle string `json:"playlist_title"` // Playlist title
PlaylistUploader string `json:"playlist_uploader"` // Full name of the playlist uploader
PlaylistUploaderID string `json:"playlist_uploader_id"` // Nickname or id of the playlist uploader
// Available for the video that belongs to some logical chapter or section:
Chapter string `json:"chapter"` // Name or title of the chapter the video belongs to
ChapterNumber float64 `json:"chapter_number"` // Number of the chapter the video belongs to
ChapterID string `json:"chapter_id"` // Id of the chapter the video belongs to
// Available for the video that is an episode of some series or programme:
Series string `json:"series"` // Title of the series or programme the video episode belongs to
Season string `json:"season"` // Title of the season the video episode belongs to
SeasonNumber float64 `json:"season_number"` // Number of the season the video episode belongs to
SeasonID string `json:"season_id"` // Id of the season the video episode belongs to
Episode string `json:"episode"` // Title of the video episode
EpisodeNumber float64 `json:"episode_number"` // Number of the video episode within a season
EpisodeID string `json:"episode_id"` // Id of the video episode
// Available for the media that is a track or a part of a music album:
Track string `json:"track"` // Title of the track
TrackNumber float64 `json:"track_number"` // Number of the track within an album or a disc
TrackID string `json:"track_id"` // Id of the track
Artist string `json:"artist"` // Artist(s) of the track
Genre string `json:"genre"` // Genre(s) of the track
Album string `json:"album"` // Title of the album the track belongs to
AlbumType string `json:"album_type"` // Type of the album
AlbumArtist string `json:"album_artist"` // List of all artists appeared on the album
DiscNumber float64 `json:"disc_number"` // Number of the disc or other physical medium the track belongs to
ReleaseYear float64 `json:"release_year"` // Year (YYYY) when the album was released
Type string `json:"_type"`
Direct bool `json:"direct"`
WebpageURL string `json:"webpage_url"`
Description string `json:"description"`
Thumbnail string `json:"thumbnail"`
// don't unmarshal, populated from image thumbnail file
ThumbnailBytes []byte `json:"-"`
Thumbnails []Thumbnail `json:"thumbnails"`
Formats []Format `json:"formats"`
Subtitles map[string][]Subtitle `json:"subtitles"`
// Playlist entries if _type is playlist
Entries []Info `json:"entries"`
// Info can also be a mix of Info and one Format
Format
}
func infoFromURL(
ctx context.Context,
rawURL string,
options Options,
) (info Info, rawJSON []byte, err error) {
cmd := exec.CommandContext(
ctx,
FindBin(),
// see comment below about ignoring errors for playlists
"--ignore-errors",
// TODO: deprecated in yt-dlp?
"--no-call-home",
// use safer output filenmaes
// TODO: needed?
"--restrict-filenames",
// use .netrc authentication data
"--netrc",
// provide url via stdin for security, youtube-dl has some run command args
"--batch-file", "-",
// dump info json
"--dump-single-json",
)
if options.ProxyUrl != "" {
cmd.Args = append(cmd.Args, "--proxy", options.ProxyUrl)
}
if options.UseIPV4 {
cmd.Args = append(cmd.Args, "-4")
}
if options.Downloader != "" {
cmd.Args = append(cmd.Args, "--downloader", options.Downloader)
}
if options.Impersonate != "" {
cmd.Args = append(cmd.Args, "--impersonate", options.Impersonate)
}
if options.Cookies != "" {
cmd.Args = append(cmd.Args, "--cookies", options.Cookies)
}
if options.CookiesFromBrowser != "" {
cmd.Args = append(cmd.Args, "--cookies-from-browser", options.CookiesFromBrowser)
}
switch options.Type {
case TypePlaylist, TypeChannel:
cmd.Args = append(cmd.Args, "--yes-playlist")
if options.PlaylistStart > 0 {
cmd.Args = append(cmd.Args,
"--playlist-start", strconv.Itoa(int(options.PlaylistStart)),
)
}
if options.PlaylistEnd > 0 {
cmd.Args = append(cmd.Args,
"--playlist-end", strconv.Itoa(int(options.PlaylistEnd)),
)
}
if options.FlatPlaylist {
cmd.Args = append(cmd.Args, "--flat-playlist")
}
case TypeSingle:
if options.DownloadSubtitles {
cmd.Args = append(cmd.Args,
"--all-subs",
)
}
cmd.Args = append(cmd.Args,
"--no-playlist",
)
case TypeAny:
break
default:
return Info{}, nil, fmt.Errorf("unhandled options type value: %d", options.Type)
}
tempPath, _ := os.MkdirTemp("", "ydls")
defer os.RemoveAll(tempPath)
stdoutBuf := &bytes.Buffer{}
stderrBuf := &bytes.Buffer{}
stderrWriter := io.Discard
if options.StderrFn != nil {
stderrWriter = options.StderrFn(cmd)
}
cmd.Dir = tempPath
cmd.Stdout = stdoutBuf
cmd.Stderr = io.MultiWriter(stderrBuf, stderrWriter)
cmd.Stdin = bytes.NewBufferString(rawURL + "\n")
log.Trace("cmd", " ", cmd.Args)
cmdErr := cmd.Run()
stderrLineScanner := bufio.NewScanner(stderrBuf)
errMessage := ""
for stderrLineScanner.Scan() {
const errorPrefix = "ERROR: "
line := stderrLineScanner.Text()
if strings.HasPrefix(line, errorPrefix) {
errMessage = line[len(errorPrefix):]
}
}
infoSeemsOk := false
if len(stdoutBuf.Bytes()) > 0 {
if infoErr := json.Unmarshal(stdoutBuf.Bytes(), &info); infoErr != nil {
return Info{}, nil, infoErr
}
isPlaylist := info.Type == "playlist" || info.Type == "multi_video"
switch {
case options.Type == TypePlaylist && !isPlaylist:
return Info{}, nil, ErrNotAPlaylist
case options.Type == TypeSingle && isPlaylist:
return Info{}, nil, ErrNotASingleEntry
default:
// any type
}
// HACK: --ignore-errors still return error message and exit code != 0
// so workaround is to assume things went ok if we get some ok json on stdout
infoSeemsOk = info.ID != ""
}
if !infoSeemsOk {
if errMessage != "" {
return Info{}, nil, YoutubedlError(errMessage)
} else if cmdErr != nil {
return Info{}, nil, cmdErr
}
return Info{}, nil, fmt.Errorf("unknown error")
}
get := func(url string) (*http.Response, error) {
c := http.DefaultClient
if options.HttpClient != nil {
c = options.HttpClient
}
r, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
for k, v := range info.HTTPHeaders {
r.Header.Set(k, v)
}
return c.Do(r)
}
if options.DownloadThumbnail && info.Thumbnail != "" {
resp, respErr := get(info.Thumbnail)
if respErr == nil {
buf, _ := io.ReadAll(resp.Body)
resp.Body.Close()
info.ThumbnailBytes = buf
}
}
for language, subtitles := range info.Subtitles {
for i := range subtitles {
subtitles[i].Language = language
}
}
if options.DownloadSubtitles {
for _, subtitles := range info.Subtitles {
for i, subtitle := range subtitles {
resp, respErr := get(subtitle.URL)
if respErr == nil {
buf, _ := io.ReadAll(resp.Body)
resp.Body.Close()
subtitles[i].Bytes = buf
}
}
}
}
// as we ignore errors for playlists some entries might show up as null
//
// note: instead of doing full recursion, we assume entries in
// playlists and channels are at most 2 levels deep, and we just
// collect entries from both levels.
//
// the following cases have not been tested:
//
// - entries that are more than 2 levels deep (will be missed)
// - the ability to restrict entries to a single level (we include both levels)
if options.Type == TypePlaylist || options.Type == TypeChannel {
var filteredEntries []Info
for _, e := range info.Entries {
if e.Type == "playlist" {
for _, ee := range e.Entries {
if ee.ID == "" {
continue
}
filteredEntries = append(filteredEntries, ee)
}
continue
} else if e.ID != "" {
filteredEntries = append(filteredEntries, e)
}
}
info.Entries = filteredEntries
}
return info, stdoutBuf.Bytes(), nil
}

View File

@@ -0,0 +1,30 @@
package ytdl
import (
"context"
)
// New downloads metadata for URL
func New(ctx context.Context, rawURL string, options Options) (result Result, err error) {
if options.noInfoDownload {
return Result{
RawURL: rawURL,
Options: options,
}, nil
}
info, rawJSON, err := infoFromURL(ctx, rawURL, options)
if err != nil {
return Result{}, err
}
rawJSONCopy := make([]byte, len(rawJSON))
copy(rawJSONCopy, rawJSON)
return Result{
Info: info,
RawURL: rawURL,
RawJSON: rawJSONCopy,
Options: options,
}, nil
}

View File

@@ -0,0 +1,228 @@
package ytdl
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path"
"strconv"
"strings"
)
// Options for New()
type Options struct {
Type Type
PlaylistStart uint // --playlist-start
PlaylistEnd uint // --playlist-end
FlatPlaylist bool // --flat-playlist, faster fetching but with less video info for playlists
Downloader string // --downloader
DownloadThumbnail bool
DownloadSubtitles bool
DownloadSections string // --download-sections
Impersonate string // --impersonate
ProxyUrl string // --proxy URL http://host:port or socks5://host:port
UseIPV4 bool // -4 Make all connections via IPv4
Cookies string // --cookies FILE
CookiesFromBrowser string // --cookies-from-browser BROWSER[:FOLDER]
StderrFn func(cmd *exec.Cmd) io.Writer // if not nil, function to get Writer for stderr
HttpClient *http.Client // Client for download thumbnail and subtitles (nil use http.DefaultClient)
MergeOutputFormat string // --merge-output-format
SortingFormat string // --format-sort
// Set to true if you don't want to use the result.Info structure after the goutubedl.New() call,
// so the given URL will be downloaded in a single pass in the DownloadResult.Download() call.
noInfoDownload bool
}
type DownloadOptions struct {
AudioFormats string // --audio-formats Download audio using formats (best, aac, alac, flac, m4a, mp3, opus, vorbis, wav)
DownloadAudioOnly bool // -x Download audio only from video
// Download format matched by filter (usually a format id or quality designator).
// If filter is empty, then youtube-dl will use its default format selector.
Filter string
// The index of the entry to download from the playlist that would be
// passed to youtube-dl via --playlist-items. The index value starts at 1
PlaylistIndex int
}
func (result Result) DownloadWithOptions(
ctx context.Context,
options DownloadOptions,
) (*DownloadResult, error) {
if !result.Options.noInfoDownload {
if (result.Info.Type == "playlist" ||
result.Info.Type == "multi_video" ||
result.Info.Type == "channel") &&
options.PlaylistIndex == 0 {
return nil, fmt.Errorf(
"can't download a playlist when the playlist index options is not set",
)
}
}
tempPath, tempErr := os.MkdirTemp("", "ydls")
if tempErr != nil {
return nil, tempErr
}
var jsonTempPath string
if !result.Options.noInfoDownload {
jsonTempPath = path.Join(tempPath, "info.json")
if err := os.WriteFile(jsonTempPath, result.RawJSON, 0600); err != nil {
os.RemoveAll(tempPath)
return nil, err
}
}
dr := &DownloadResult{
waitCh: make(chan struct{}),
}
cmd := exec.CommandContext(
ctx,
FindBin(),
// see comment below about ignoring errors for playlists
"--ignore-errors",
// TODO: deprecated in yt-dlp?
"--no-call-home",
// use non-fancy progress bar
"--newline",
// use safer output filenmaes
// TODO: needed?
"--restrict-filenames",
// use .netrc authentication data
"--netrc",
// write to stdout
"--output", "-",
)
if result.Options.noInfoDownload {
// provide URL via stdin for security, youtube-dl has some run command args
cmd.Args = append(cmd.Args, "--batch-file", "-")
cmd.Stdin = bytes.NewBufferString(result.RawURL + "\n")
if result.Options.Type == TypePlaylist {
cmd.Args = append(cmd.Args, "--yes-playlist")
if result.Options.PlaylistStart > 0 {
cmd.Args = append(cmd.Args,
"--playlist-start", strconv.Itoa(int(result.Options.PlaylistStart)),
)
}
if result.Options.PlaylistEnd > 0 {
cmd.Args = append(cmd.Args,
"--playlist-end", strconv.Itoa(int(result.Options.PlaylistEnd)),
)
}
} else {
cmd.Args = append(cmd.Args,
"--no-playlist",
)
}
} else {
cmd.Args = append(cmd.Args, "--load-info", jsonTempPath)
}
// force IPV4 Usage
if result.Options.UseIPV4 {
cmd.Args = append(cmd.Args, "-4")
}
// don't need to specify if direct as there is only one
// also seems to be issues when using filter with generic extractor
if !result.Info.Direct && options.Filter != "" {
cmd.Args = append(cmd.Args, "-f", options.Filter)
}
if options.PlaylistIndex > 0 {
cmd.Args = append(cmd.Args, "--playlist-items", fmt.Sprint(options.PlaylistIndex))
}
if options.DownloadAudioOnly {
cmd.Args = append(cmd.Args, "-x")
}
if options.AudioFormats != "" {
cmd.Args = append(cmd.Args, "--audio-format", options.AudioFormats)
}
if result.Options.ProxyUrl != "" {
cmd.Args = append(cmd.Args, "--proxy", result.Options.ProxyUrl)
}
if result.Options.Downloader != "" {
cmd.Args = append(cmd.Args, "--downloader", result.Options.Downloader)
}
if result.Options.DownloadSections != "" {
cmd.Args = append(cmd.Args, "--download-sections", result.Options.DownloadSections)
}
if result.Options.CookiesFromBrowser != "" {
cmd.Args = append(cmd.Args, "--cookies-from-browser", result.Options.CookiesFromBrowser)
}
if result.Options.MergeOutputFormat != "" {
cmd.Args = append(cmd.Args,
"--merge-output-format", result.Options.MergeOutputFormat,
)
}
if result.Options.SortingFormat != "" {
cmd.Args = append(cmd.Args,
"--format-sort", result.Options.SortingFormat,
)
}
cmd.Dir = tempPath
var stdoutW io.WriteCloser
var stderrW io.WriteCloser
var stderrR io.Reader
dr.reader, stdoutW = io.Pipe()
stderrR, stderrW = io.Pipe()
optStderrWriter := io.Discard
if result.Options.StderrFn != nil {
optStderrWriter = result.Options.StderrFn(cmd)
}
cmd.Stdout = stdoutW
cmd.Stderr = io.MultiWriter(optStderrWriter, stderrW)
log.Trace("cmd", " ", cmd.Args)
if err := cmd.Start(); err != nil {
os.RemoveAll(tempPath)
return nil, err
}
go func() {
_ = cmd.Wait()
stdoutW.Close()
stderrW.Close()
os.RemoveAll(tempPath)
close(dr.waitCh)
}()
// blocks return until yt-dlp is downloading or has errored
ytErrCh := make(chan error)
go func() {
stderrLineScanner := bufio.NewScanner(stderrR)
for stderrLineScanner.Scan() {
const downloadPrefix = "[download]"
const errorPrefix = "ERROR: "
line := stderrLineScanner.Text()
if strings.HasPrefix(line, downloadPrefix) {
break
} else if strings.HasPrefix(line, errorPrefix) {
ytErrCh <- errors.New(line[len(errorPrefix):])
return
}
}
ytErrCh <- nil
_, _ = io.Copy(io.Discard, stderrR)
}()
return dr, <-ytErrCh
}

View File

@@ -0,0 +1,49 @@
package ytdl
import (
"context"
"io"
)
// Result metadata for a URL
type Result struct {
Info Info
RawURL string
RawJSON []byte // saved raw JSON. Used later when downloading
Options Options // options passed to New
}
// DownloadResult download result
type DownloadResult struct {
reader io.ReadCloser
waitCh chan struct{}
}
// Download format matched by filter (usually a format id or quality designator).
// If filter is empty, then youtube-dl will use its default format selector.
// It's a shortcut of DownloadWithOptions where the options use the default value
func (result Result) Download(ctx context.Context, filter string) (*DownloadResult, error) {
return result.DownloadWithOptions(ctx, DownloadOptions{
Filter: filter,
})
}
func (dr *DownloadResult) Read(p []byte) (n int, err error) {
return dr.reader.Read(p)
}
// Close downloader and wait for resource cleanup
func (dr *DownloadResult) Close() error {
err := dr.reader.Close()
<-dr.waitCh
return err
}
// Formats return all formats
// helper to take care of mixed info and format
func (result Result) Formats() []Format {
if len(result.Info.Formats) > 0 {
return result.Info.Formats
}
return []Format{result.Info.Format}
}

View File

@@ -0,0 +1,10 @@
package ytdl
// Subtitle youtube-dl subtitle
type Subtitle struct {
URL string `json:"url"`
Ext string `json:"ext"`
Language string `json:"-"`
// don't unmarshal, populated from subtitle file
Bytes []byte `json:"-"`
}

View File

View File

@@ -0,0 +1,10 @@
package ytdl
type Thumbnail struct {
ID string `json:"id"`
URL string `json:"url"`
Preference int `json:"preference"`
Width int `json:"width"`
Height int `json:"height"`
Resolution string `json:"resolution"`
}

View File

@@ -0,0 +1,22 @@
package ytdl
// Type of response you want
type Type int
const (
// TypeAny single or playlist (default)
TypeAny Type = iota
// TypeSingle single track, file etc
TypeSingle
// TypePlaylist playlist with multiple tracks, files etc
TypePlaylist
// TypeChannel channel containing one or more playlists, which will be flattened
TypeChannel
)
var TypeFromString = map[string]Type{
"any": TypeAny,
"single": TypeSingle,
"playlist": TypePlaylist,
"channel": TypeChannel,
}

View File

@@ -0,0 +1,19 @@
package ytdl
import (
"context"
"os/exec"
"strings"
)
// Version of youtube-dl.
// Might be a good idea to call at start to assert that youtube-dl can be found.
func Version(ctx context.Context) (string, error) {
cmd := exec.CommandContext(ctx, FindBin(), "--version")
versionBytes, cmdErr := cmd.Output()
if cmdErr != nil {
return "", cmdErr
}
return strings.TrimSpace(string(versionBytes)), nil
}

View File

@@ -0,0 +1,32 @@
package ytdl
import (
"context"
"regexp"
"strings"
"testing"
)
func TestVersion(t *testing.T) {
t.Run("Success", func(t *testing.T) {
versionRe := regexp.MustCompile(`^\d{4}\.\d{2}.\d{2}.*$`)
version, versionErr := Version(context.Background())
if versionErr != nil {
t.Fatalf("err: %s", versionErr)
}
if !versionRe.MatchString(version) {
t.Errorf("version %q does not match %q", version, versionRe)
}
})
t.Run("InvalidBin", func(t *testing.T) {
defer func(orig string) { Bin = orig }(Bin)
Bin = "/non-existing"
_, versionErr := Version(context.Background())
if versionErr == nil || !strings.Contains(versionErr.Error(), "no such file or directory") {
t.Fatalf("err should be nil 'no such file or directory': %v", versionErr)
}
})
}

View File

@@ -0,0 +1,37 @@
/*
Package ytdl provides media download functionality.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
This code is copied and modified in part from:
- https://github.com/wader/goutubedl
MIT License, Copyright (c) 2019 Mattias Wadman
see https://github.com/wader/goutubedl?tab=MIT-1-ov-file#readme
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package ytdl
import (
"github.com/photoprism/photoprism/internal/event"
)
var log = event.Log

View File

@@ -0,0 +1,499 @@
package ytdl
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
"testing"
"github.com/photoprism/photoprism/pkg/fs"
)
const (
testVideoRawURL = "https://vimeo.com/454525548"
playlistRawURL = "https://soundcloud.com/mattheis/sets/kindred-phenomena"
channelRawURL = "https://www.youtube.com/channel/UCHDm-DKoMyJxKVgwGmuTaQA"
subtitlesTestVideoRawURL = "https://www.youtube.com/watch?v=QRS8MkLhQmM"
)
func TestParseInfo(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
for _, c := range []struct {
url string
expectedTitle string
}{
{"https://soundcloud.com/avalonemerson/avalon-emerson-live-at-printworks-london-march-2017", "Avalon Emerson Live at Printworks London 2017"},
{"https://www.infoq.com/presentations/Simple-Made-Easy", "Simple Made Easy - InfoQ"},
{"https://vimeo.com/454525548", "Sample Video - 3 minutemp4.mp4"},
} {
t.Run(c.url, func(t *testing.T) {
ctx, cancelFn := context.WithCancel(context.Background())
ydlResult, err := New(ctx, c.url, Options{
DownloadThumbnail: true,
})
if err != nil {
cancelFn()
t.Errorf("failed to parse: %v", err)
return
}
cancelFn()
yi := ydlResult.Info
results := ydlResult.Formats()
if yi.Title != c.expectedTitle {
t.Errorf("expected title %q got %q", c.expectedTitle, yi.Title)
}
if yi.Thumbnail != "" && len(yi.ThumbnailBytes) == 0 {
t.Errorf("expected thumbnail bytes")
}
var dummy map[string]interface{}
if err := json.Unmarshal(ydlResult.RawJSON, &dummy); err != nil {
t.Errorf("failed to parse RawJSON")
}
if len(results) == 0 {
t.Errorf("expected formats")
}
for _, f := range results {
if f.FormatID == "" {
t.Errorf("expected to have FormatID")
}
if f.Ext == "" {
t.Errorf("expected to have Ext")
}
if (f.ACodec == "" || f.ACodec == "none") &&
(f.VCodec == "" || f.VCodec == "none") &&
f.Ext == "" {
t.Errorf("expected to have some media: audio %q video %q ext %q", f.ACodec, f.VCodec, f.Ext)
}
}
})
}
}
func TestPlaylist(t *testing.T) {
ydlResult, ydlResultErr := New(context.Background(), playlistRawURL, Options{
Type: TypePlaylist,
DownloadThumbnail: false,
})
if ydlResultErr != nil {
t.Errorf("failed to download: %s", ydlResultErr)
}
expectedTitle := "Kindred Phenomena"
if ydlResult.Info.Title != expectedTitle {
t.Errorf("expected title %q got %q", expectedTitle, ydlResult.Info.Title)
}
expectedEntries := 8
if len(ydlResult.Info.Entries) != expectedEntries {
t.Errorf("expected %d entries got %d", expectedEntries, len(ydlResult.Info.Entries))
}
expectedTitleOne := "A1 Mattheis - Herds"
if ydlResult.Info.Entries[0].Title != expectedTitleOne {
t.Errorf("expected title %q got %q", expectedTitleOne, ydlResult.Info.Entries[0].Title)
}
}
func TestChannel(t *testing.T) {
t.Skip("skip youtube for now")
ydlResult, ydlResultErr := New(
context.Background(),
channelRawURL,
Options{
Type: TypeChannel,
DownloadThumbnail: false,
},
)
if ydlResultErr != nil {
t.Errorf("failed to download: %s", ydlResultErr)
}
expectedTitle := "Simon Yapp"
if ydlResult.Info.Title != expectedTitle {
t.Errorf("expected title %q got %q", expectedTitle, ydlResult.Info.Title)
}
expectedEntries := 5
if len(ydlResult.Info.Entries) != expectedEntries {
t.Errorf("expected %d entries got %d", expectedEntries, len(ydlResult.Info.Entries))
}
expectedTitleOne := "#RNLI Shoreham #LifeBoat demo of launch."
if ydlResult.Info.Entries[0].Title != expectedTitleOne {
t.Errorf("expected title %q got %q", expectedTitleOne, ydlResult.Info.Entries[0].Title)
}
}
func TestUnsupportedURL(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
_, ydlResultErr := New(context.Background(), "https://www.google.com", Options{})
if ydlResultErr == nil {
t.Errorf("expected unsupported url")
}
expectedErrPrefix := "Unsupported URL:"
if ydlResultErr != nil && !strings.HasPrefix(ydlResultErr.Error(), expectedErrPrefix) {
t.Errorf("expected error prefix %q got %q", expectedErrPrefix, ydlResultErr.Error())
}
}
func TestPlaylistWithPrivateVideo(t *testing.T) {
t.Skip("skip youtube for now")
plRawURL := "https://www.youtube.com/playlist?list=PLX0g748fkegS54oiDN4AXKl7BR7mLIydP"
ydlResult, ydlResultErr := New(context.Background(), plRawURL, Options{
Type: TypePlaylist,
DownloadThumbnail: false,
})
if ydlResultErr != nil {
t.Errorf("failed to download: %s", ydlResultErr)
}
expectedLen := 2
actualLen := len(ydlResult.Info.Entries)
if expectedLen != actualLen {
t.Errorf("expected len %d got %d", expectedLen, actualLen)
}
}
func TestSubtitles(t *testing.T) {
t.Skip("skip youtube for now")
ydlResult, ydlResultErr := New(
context.Background(),
subtitlesTestVideoRawURL,
Options{
DownloadSubtitles: true,
})
if ydlResultErr != nil {
t.Errorf("failed to download: %s", ydlResultErr)
}
for _, subtitles := range ydlResult.Info.Subtitles {
for _, subtitle := range subtitles {
if subtitle.Ext == "" {
t.Errorf("%s: %s: expected extension", ydlResult.Info.URL, subtitle.Language)
}
if subtitle.Language == "" {
t.Errorf("%s: %s: expected language", ydlResult.Info.URL, subtitle.Language)
}
if subtitle.URL == "" {
t.Errorf("%s: %s: expected url", ydlResult.Info.URL, subtitle.Language)
}
if len(subtitle.Bytes) == 0 {
t.Errorf("%s: %s: expected bytes", ydlResult.Info.URL, subtitle.Language)
}
}
}
}
func TestDownloadSections(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
fileName := fs.Abs("./testdata/duration_test_file")
duration := 5
cmd := exec.Command(FindFFmpegBin(), "-version")
_, err := cmd.Output()
if err != nil {
t.Errorf("failed to check ffmpeg installed: %s", err)
}
ydlResult, ydlResultErr := New(
context.Background(),
"https://vimeo.com/454525548",
Options{
DownloadSections: fmt.Sprintf("*0:0-0:%d", duration),
})
if ydlResult.Options.DownloadSections != "*0:0-0:5" {
t.Errorf("failed to setup --download-sections")
}
if ydlResultErr != nil {
t.Errorf("failed to download: %s", ydlResultErr)
}
dr, err := ydlResult.Download(context.Background(), "")
if err != nil {
t.Fatal(err)
}
f, err := os.Create(fileName)
if err != nil {
t.Fatal(err)
}
defer f.Close()
_, err = io.Copy(f, dr)
if err != nil {
t.Fatal(err)
}
cmd = exec.Command(FindFFprobeBin(), "-v", "quiet", "-show_entries", "format=duration", fileName)
stdout, err := cmd.Output()
if err != nil {
t.Fatal(err)
}
var gotDurationString string
output := string(stdout)
for _, line := range strings.Split(output, "\n") {
if strings.Contains(line, "duration") {
if d, found := strings.CutPrefix(line, "duration="); found {
gotDurationString = d
}
}
}
gotDuration, err := strconv.ParseFloat(gotDurationString, 32)
if err != nil {
t.Fatal(err)
}
seconds := int(gotDuration)
if seconds != duration {
t.Fatalf("did not get expected duration of %d, but got %d", duration, seconds)
}
_ = dr.Close()
_ = os.Remove(fileName)
}
func TestErrorNotAPlaylist(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
_, ydlResultErr := New(context.Background(), testVideoRawURL, Options{
Type: TypePlaylist,
DownloadThumbnail: false,
})
if ydlResultErr != ErrNotAPlaylist {
t.Errorf("expected is playlist error, got %s", ydlResultErr)
}
}
func TestErrorNotASingleEntry(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
_, ydlResultErr := New(context.Background(), playlistRawURL, Options{
Type: TypeSingle,
DownloadThumbnail: false,
})
if ydlResultErr != ErrNotASingleEntry {
t.Fatalf("expected is single entry error, got %s", ydlResultErr)
}
}
func TestOptionDownloader(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
ydlResult, ydlResultErr := New(
context.Background(),
testVideoRawURL,
Options{
Downloader: "ffmpeg",
})
if ydlResultErr != nil {
t.Fatalf("failed to download: %s", ydlResultErr)
}
dr, err := ydlResult.Download(context.Background(), ydlResult.Info.Formats[0].FormatID)
if err != nil {
t.Fatal(err)
}
downloadBuf := &bytes.Buffer{}
_, err = io.Copy(downloadBuf, dr)
if err != nil {
t.Fatal(err)
}
dr.Close()
}
func TestInvalidOptionTypeField(t *testing.T) {
_, err := New(context.Background(), playlistRawURL, Options{
Type: 42,
})
if err == nil {
t.Error("should have failed")
}
}
func TestDownloadPlaylistEntry(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode.")
}
// Download file by specifying the playlist index
stderrBuf := &bytes.Buffer{}
r, err := New(context.Background(), playlistRawURL, Options{
StderrFn: func(cmd *exec.Cmd) io.Writer {
return stderrBuf
},
})
if err != nil {
t.Fatal(err)
}
expectedTitle := "Kindred Phenomena"
if r.Info.Title != expectedTitle {
t.Errorf("expected title %q got %q", expectedTitle, r.Info.Title)
}
expectedEntries := 8
if len(r.Info.Entries) != expectedEntries {
t.Errorf("expected %d entries got %d", expectedEntries, len(r.Info.Entries))
}
expectedTitleOne := "B1 Mattheis - Ben M"
playlistIndex := 2
if r.Info.Entries[playlistIndex].Title != expectedTitleOne {
t.Errorf("expected title %q got %q", expectedTitleOne, r.Info.Entries[playlistIndex].Title)
}
dr, err := r.DownloadWithOptions(context.Background(), DownloadOptions{
PlaylistIndex: int(r.Info.Entries[playlistIndex].PlaylistIndex),
Filter: r.Info.Entries[playlistIndex].Formats[0].FormatID,
})
if err != nil {
t.Fatal(err)
}
playlistBuf := &bytes.Buffer{}
n, err := io.Copy(playlistBuf, dr)
if err != nil {
t.Fatal(err)
}
dr.Close()
if n != int64(playlistBuf.Len()) {
t.Errorf("copy n not equal to download buffer: %d!=%d", n, playlistBuf.Len())
}
if n < 10000 {
t.Errorf("should have copied at least 10000 bytes: %d", n)
}
if !strings.Contains(stderrBuf.String(), "Destination") {
t.Errorf("did not find expected log message on stderr: %q", stderrBuf.String())
}
// Download the same file but with the direct link
url := "https://soundcloud.com/mattheis/b1-mattheis-ben-m"
stderrBuf = &bytes.Buffer{}
r, err = New(context.Background(), url, Options{
StderrFn: func(cmd *exec.Cmd) io.Writer {
return stderrBuf
},
})
if err != nil {
t.Fatal(err)
}
if r.Info.Title != expectedTitleOne {
t.Errorf("expected title %q got %q", expectedTitleOne, r.Info.Title)
}
expectedEntries = 0
if len(r.Info.Entries) != expectedEntries {
t.Errorf("expected %d entries got %d", expectedEntries, len(r.Info.Entries))
}
dr, err = r.Download(context.Background(), r.Info.Formats[0].FormatID)
if err != nil {
t.Fatal(err)
}
directLinkBuf := &bytes.Buffer{}
n, err = io.Copy(directLinkBuf, dr)
if err != nil {
t.Fatal(err)
}
dr.Close()
if n != int64(directLinkBuf.Len()) {
t.Errorf("copy n not equal to download buffer: %d!=%d", n, directLinkBuf.Len())
}
if n < 10000 {
t.Errorf("should have copied at least 10000 bytes: %d", n)
}
if !strings.Contains(stderrBuf.String(), "Destination") {
t.Errorf("did not find expected log message on stderr: %q", stderrBuf.String())
}
if directLinkBuf.Len() != playlistBuf.Len() {
t.Errorf("not the same content size between the playlist index entry and the direct link entry: %d != %d", playlistBuf.Len(), directLinkBuf.Len())
}
if !bytes.Equal(directLinkBuf.Bytes(), playlistBuf.Bytes()) {
t.Error("not the same content between the playlist index entry and the direct link entry")
}
}
func TestFormatDownloadError(t *testing.T) {
t.Skip("test URL broken")
ydl, ydlErr := New(
context.Background(),
"https://www.reddit.com/r/newsbabes/s/92rflI0EB0",
Options{},
)
if ydlErr != nil {
// reddit seems to not like github action hosts
if strings.Contains(ydlErr.Error(), "HTTPError 403: Blocked") {
t.Skip()
}
t.Error(ydlErr)
}
// no pre-muxed audio/video format available
_, ytDlErr := ydl.Download(context.Background(), "best")
expectedErr := "Requested format is not available"
if ydlErr != nil && !strings.Contains(ytDlErr.Error(), expectedErr) {
t.Errorf("expected error prefix %q got %q", expectedErr, ytDlErr.Error())
}
}