mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 21:01:58 +08:00
Import: Add ytdl package for downloading videos from URLs #4982
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
6
go.mod
6
go.mod
@@ -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
12
go.sum
@@ -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=
|
||||
|
@@ -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) {
|
||||
|
@@ -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"
|
||||
|
@@ -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.
|
||||
|
@@ -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)
|
||||
})
|
||||
}
|
||||
|
@@ -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.
|
||||
|
@@ -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.
|
||||
|
@@ -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.
|
||||
|
@@ -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.
|
||||
|
@@ -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.
|
||||
|
@@ -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) {
|
||||
|
@@ -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 (
|
||||
|
39
internal/photoprism/ytdl/bin.go
Normal file
39
internal/photoprism/ytdl/bin.go
Normal 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
|
||||
}
|
20
internal/photoprism/ytdl/bin_test.go
Normal file
20
internal/photoprism/ytdl/bin_test.go
Normal 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'")
|
||||
}
|
21
internal/photoprism/ytdl/download.go
Normal file
21
internal/photoprism/ytdl/download.go
Normal 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)
|
||||
}
|
82
internal/photoprism/ytdl/download_test.go
Normal file
82
internal/photoprism/ytdl/download_test.go
Normal 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())
|
||||
}
|
||||
}
|
18
internal/photoprism/ytdl/error.go
Normal file
18
internal/photoprism/ytdl/error.go
Normal 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")
|
39
internal/photoprism/ytdl/format.go
Normal file
39
internal/photoprism/ytdl/format.go
Normal 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,
|
||||
)
|
||||
}
|
314
internal/photoprism/ytdl/info.go
Normal file
314
internal/photoprism/ytdl/info.go
Normal 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
|
||||
}
|
30
internal/photoprism/ytdl/new.go
Normal file
30
internal/photoprism/ytdl/new.go
Normal 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
|
||||
}
|
228
internal/photoprism/ytdl/options.go
Normal file
228
internal/photoprism/ytdl/options.go
Normal 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
|
||||
}
|
49
internal/photoprism/ytdl/result.go
Normal file
49
internal/photoprism/ytdl/result.go
Normal 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}
|
||||
}
|
10
internal/photoprism/ytdl/subtitle.go
Normal file
10
internal/photoprism/ytdl/subtitle.go
Normal 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:"-"`
|
||||
}
|
0
internal/photoprism/ytdl/testdata/.gitkeep
vendored
Normal file
0
internal/photoprism/ytdl/testdata/.gitkeep
vendored
Normal file
10
internal/photoprism/ytdl/thumbnail.go
Normal file
10
internal/photoprism/ytdl/thumbnail.go
Normal 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"`
|
||||
}
|
22
internal/photoprism/ytdl/type.go
Normal file
22
internal/photoprism/ytdl/type.go
Normal 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,
|
||||
}
|
19
internal/photoprism/ytdl/version.go
Normal file
19
internal/photoprism/ytdl/version.go
Normal 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
|
||||
}
|
32
internal/photoprism/ytdl/version_test.go
Normal file
32
internal/photoprism/ytdl/version_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
37
internal/photoprism/ytdl/ytdl.go
Normal file
37
internal/photoprism/ytdl/ytdl.go
Normal 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
|
499
internal/photoprism/ytdl/ytdl_test.go
Normal file
499
internal/photoprism/ytdl/ytdl_test.go
Normal 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())
|
||||
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user