Videos: Improve downloading, remuxing, and transcoding #4982 #4892 #5040

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-06-09 15:31:23 +02:00
parent a45d9d30b9
commit 2e2ebab433
67 changed files with 936 additions and 258 deletions

View File

@@ -1,5 +1,5 @@
# Ubuntu 25.04 (Plucky Puffin)
FROM photoprism/develop:250524-plucky
FROM photoprism/develop:250608-plucky
## Alternative Environments:
# FROM photoprism/develop:armv7 # ARMv7 (32bit)

View File

@@ -74,6 +74,7 @@ test: test-js test-go
test-go: reset-sqlite run-test-go
test-pkg: reset-sqlite run-test-pkg
test-api: reset-sqlite run-test-api
test-video: reset-sqlite run-test-video
test-entity: reset-sqlite run-test-entity
test-commands: reset-sqlite run-test-commands
test-photoprism: reset-sqlite run-test-photoprism
@@ -376,6 +377,9 @@ run-test-pkg:
run-test-api:
$(info Running all API tests...)
$(GOTEST) -parallel 2 -count 1 -cpu 2 -tags="slow,develop" -timeout 20m ./internal/api/...
run-test-video:
$(info Running all video tests...)
$(GOTEST) -parallel 2 -count 1 -cpu 2 -tags="slow,develop" -timeout 20m ./internal/ffmpeg/... ./internal/photoprism/dl/... ./pkg/media/...
run-test-entity:
$(info Running all Entity tests...)
$(GOTEST) -parallel 2 -count 1 -cpu 2 -tags="slow,develop" -timeout 20m ./internal/entity/...

BIN
assets/examples/bear.m2ts Normal file

Binary file not shown.

BIN
assets/examples/m2ts.mp4 Normal file

Binary file not shown.

View File

@@ -38,6 +38,7 @@ export const FormatWebmAv1 = "webm_av1";
export const FormatMkvAv1 = "mkv_av1";
export const FormatTheora = "ogg";
export const FormatWebp = "webp";
export const FormatM2TS = "m2t";
// Image file formats:
export const FormatJpeg = "jpg";

View File

@@ -423,8 +423,9 @@ export default class $util {
return "Matroska Multimedia Container";
case "mts":
return "Advanced Video Coding High Definition (AVCHD)";
case "m2t":
case "m2ts":
return "Blu-ray MPEG-2 Transport Stream";
return "MPEG-2 Transport Stream (M2TS)";
case "webp":
return "Google WebP";
case media.FormatWebm:
@@ -524,6 +525,8 @@ export default class $util {
case media.CodecVp09:
case media.FormatVp9:
return "VP9";
case media.FormatM2TS:
return "M2TS";
case "extended webp":
case media.FormatWebp:
return "WebP";
@@ -578,6 +581,9 @@ export default class $util {
return "Extended WebP";
case "webm":
return "Google WebM";
case "m2t":
case "m2ts":
return "MPEG-2 Transport Stream (M2TS)";
case "mpeg":
return "Moving Picture Experts Group (MPEG)";
case "mjpg":

42
go.mod
View File

@@ -13,9 +13,9 @@ require (
github.com/dustin/go-humanize v1.0.1
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-20250509130527-0a13e5a5d53d
github.com/google/open-location-code/go v0.0.0-20250415120251-fa6d7f9d4765
github.com/gin-gonic/gin v1.10.1
github.com/golang/geo v0.0.0-20250606134707-e8fe6a72b492
github.com/google/open-location-code/go v0.0.0-20250523152404-3cf9f806af4d
github.com/gorilla/websocket v1.5.3
github.com/gosimple/slug v1.15.0
github.com/jinzhu/gorm v1.9.16
@@ -24,7 +24,7 @@ require (
github.com/karrick/godirwalk v1.17.0
github.com/klauspost/cpuid/v2 v2.2.10
github.com/leandro-lugaresi/hub v1.1.1
github.com/leonelquinteros/gotext v1.7.1
github.com/leonelquinteros/gotext v1.7.2
github.com/lib/pq v1.10.9 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mandykoh/prism v0.35.3
@@ -40,15 +40,15 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/crypto v0.38.0
golang.org/x/net v0.40.0
golang.org/x/crypto v0.39.0
golang.org/x/net v0.41.0
gonum.org/v1/gonum v0.16.0
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
golang.org/x/image v0.27.0
golang.org/x/image v0.28.0
)
require github.com/olekukonko/tablewriter v0.0.5
@@ -60,34 +60,34 @@ require github.com/chzyer/readline v1.5.1 // indirect
require github.com/gabriel-vasile/mimetype v1.4.9
require (
golang.org/x/sync v0.14.0
golang.org/x/time v0.11.0
golang.org/x/sync v0.15.0
golang.org/x/time v0.12.0
)
require github.com/go-ldap/ldap/v3 v3.4.11
require (
github.com/prometheus/client_golang v1.22.0
github.com/prometheus/common v0.63.0
github.com/prometheus/common v0.64.0
)
require github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0
require golang.org/x/text v0.25.0
require golang.org/x/text v0.26.0
require (
github.com/IGLOU-EU/go-wildcard v1.0.3
github.com/davidbyttow/govips/v2 v2.16.0
github.com/go-co-op/gocron/v2 v2.16.1
github.com/pquerna/otp v1.4.0
github.com/go-co-op/gocron/v2 v2.16.2
github.com/pquerna/otp v1.5.0
github.com/robfig/cron/v3 v3.0.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
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.38.1
golang.org/x/mod v0.24.0
github.com/zitadel/oidc/v3 v3.39.0
golang.org/x/mod v0.25.0
golang.org/x/sys v0.33.0
)
@@ -132,7 +132,7 @@ require (
github.com/muhlemmer/gu v0.3.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/procfs v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
@@ -140,7 +140,7 @@ require (
github.com/swaggo/swag v1.16.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/ugorji/go/codec v1.2.14 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
github.com/zitadel/logging v0.6.2 // indirect
github.com/zitadel/schema v1.3.1 // indirect
@@ -148,20 +148,20 @@ 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.29.0 // indirect
golang.org/x/tools v0.31.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/tools v0.33.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
require (
github.com/abema/go-mp4 v1.4.1
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic v1.13.3 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sunfish-shogi/bufseekio v0.1.0
golang.org/x/arch v0.16.0 // indirect
golang.org/x/arch v0.18.0 // indirect
)
require (

84
go.sum
View File

@@ -38,8 +38,8 @@ github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTS
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4=
github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0=
github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
@@ -127,14 +127,14 @@ github.com/gin-contrib/gzip v1.2.3 h1:dAhT722RuEG330ce2agAs75z7yB+NKvX/ZM1r8w0u2
github.com/gin-contrib/gzip v1.2.3/go.mod h1:ad72i4Bzmaypk8M762gNXa2wkxxjbz0icRNnuLJ9a/c=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-co-op/gocron/v2 v2.16.1 h1:ux/5zxVRveCaCuTtNI3DiOk581KC1KpJbpJFYUEVYwo=
github.com/go-co-op/gocron/v2 v2.16.1/go.mod h1:opexeOFy5BplhsKdA7bzY9zeYih8I8/WNJ4arTIFPVc=
github.com/go-co-op/gocron/v2 v2.16.2 h1:r08P663ikXiulLT9XaabkLypL/W9MoCIbqgQoAutyX4=
github.com/go-co-op/gocron/v2 v2.16.2/go.mod h1:4YTLGCCAH75A5RlQ6q+h+VacO7CgjkgP0EJ+BEOXRSI=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
@@ -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-20250509130527-0a13e5a5d53d h1:744gh8J7sbiKoLDyb4KMKj7DCiA2+vDVjb/nKGiv6yE=
github.com/golang/geo v0.0.0-20250509130527-0a13e5a5d53d/go.mod h1:Vaw7L5b+xa3Rj4/pRtrQkymn3lSBRB/NAEdbF9YEVLA=
github.com/golang/geo v0.0.0-20250606134707-e8fe6a72b492 h1:8mHyM6CCmj/DQAhHXJVTgdkg/6hAH71N7qGEF+t4Bzg=
github.com/golang/geo v0.0.0-20250606134707-e8fe6a72b492/go.mod h1:Vaw7L5b+xa3Rj4/pRtrQkymn3lSBRB/NAEdbF9YEVLA=
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=
@@ -209,8 +209,8 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/open-location-code/go v0.0.0-20250415120251-fa6d7f9d4765 h1:/Xn4RiCibwInR8cebnpm/muVubIUBbQcMIovW3omkQE=
github.com/google/open-location-code/go v0.0.0-20250415120251-fa6d7f9d4765/go.mod h1:eJfRN6aj+kR/rnua/rw9jAgYhqoMHldQkdTi+sePRKk=
github.com/google/open-location-code/go v0.0.0-20250523152404-3cf9f806af4d h1:Vsgdb0N5xlbjgshQmoprjFB+noSo0mioy4Xdl4rk3fg=
github.com/google/open-location-code/go v0.0.0-20250523152404-3cf9f806af4d/go.mod h1:eJfRN6aj+kR/rnua/rw9jAgYhqoMHldQkdTi+sePRKk=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
@@ -286,8 +286,8 @@ github.com/leandro-lugaresi/hub v1.1.1 h1:zqp0HzFvj4HtqjMBXM2QF17o6PNmR8MJOChgeK
github.com/leandro-lugaresi/hub v1.1.1/go.mod h1:XEFWanhHv6Rt3XlteHMxuNDYi8dJcpJjodpqkU+BtIo=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/leonelquinteros/gotext v1.7.1 h1:/JNPeE3lY5JeVYv2+KBpz39994W3W9fmZCGq3eO9Ri8=
github.com/leonelquinteros/gotext v1.7.1/go.mod h1:I0WoFDn9u2D3VbPnnDPT8mzZu0iSXG8iih+AH2fHHqg=
github.com/leonelquinteros/gotext v1.7.2 h1:bDPndU8nt+/kRo1m4l/1OXiiy2v7Z7dfPQ9+YP7G1Mc=
github.com/leonelquinteros/gotext v1.7.2/go.mod h1:9/haCkm5P7Jay1sxKDGJ5WIg4zkz8oZKw4ekNpALob8=
github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
@@ -337,15 +337,15 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.16.0 h1:xh6oHhKwnOJKMYiYBDWmkHqQPyiY40sny36Cmx2bbsM=
github.com/prometheus/procfs v0.16.0/go.mod h1:8veyXUu3nGP7oaCxhX6yeaM5u4stL2FeMXnCqhDthZg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -399,8 +399,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugjka/go-tz/v2 v2.2.6 h1:xAjw0dwSoLZYVBv1lA+n165ibSnDtHguBQNbeAMDwNE=
github.com/ugjka/go-tz/v2 v2.2.6/go.mod h1:Jh35OKbERtwjZLWDZ2KgjD+bm5hb9Lx8nVD9Mv9NVzs=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.2.14 h1:yOQvXCBc3Ij46LRkRoh4Yd5qK6LVOgi0bYOXfb7ifjw=
github.com/ugorji/go/codec v1.2.14/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6 h1:TtyC78WMafNW8QFfv3TeP3yWNDG+uxNkk9vOrnDu6JA=
github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6/go.mod h1:h8272+G2omSmi30fBXiZDMkmHuOgonplfKIKjQWzlfs=
github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
@@ -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.38.1 h1:VTf1Bv/33UbSwJnIWbfEIdpUGYKfoHetuBNIqVTcjvA=
github.com/zitadel/oidc/v3 v3.38.1/go.mod h1:muukzAasaWmn3vBwEVMglJfuTE0PKCvLJGombPwXIRw=
github.com/zitadel/oidc/v3 v3.39.0 h1:WK3eNqmgshiYo1oEqONfXXbPbve+Qzgjl8KhKDFUvxc=
github.com/zitadel/oidc/v3 v3.39.0/go.mod h1:JwdgdU/WxkmBtWuE8/pEjAbDTWXxJGqBix/gUoeEig4=
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=
@@ -433,8 +433,8 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go4.org v0.0.0-20200411211856-f5505b9728dd/go.mod h1:CIiUVy99QCPfoE13bO4EZaz5GZMZXMSBGhxRdsvzbkg=
go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@@ -446,8 +446,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -462,8 +462,8 @@ golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+o
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE=
golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -484,8 +484,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -516,15 +516,15 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
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.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
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=
@@ -536,8 +536,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -594,12 +594,12 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -628,8 +628,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
"math"
"net/url"
"os"
"path/filepath"
@@ -13,9 +14,11 @@ import (
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/ffmpeg"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/photoprism/dl"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/photoprism/ytdl"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media"
@@ -108,12 +111,17 @@ func downloadAction(ctx *cli.Context) error {
mediaType = media.Video
log.Infof("downloading %s from %s", mediaType, clean.Log(sourceUrl.String()))
result, err := ytdl.New(context.Background(), sourceUrl.String(), ytdl.Options{
MergeOutputFormat: "mp4",
RemuxVideo: "mp4",
SortingFormat: "lang,quality,res,fps,hdr:10+,vcodec:h264>av01>h265>vp9.2>vp9>h263,acodec:m4a>mp4a>aac>mp3>mp3>ac3>dts,channels,size,br,asr,proto,ext,hasaud,source,id",
PlaylistStart: 1,
})
opt := dl.Options{
// The following flags currently seem to have no effect when piping the output to stdout;
// however, that may change in a future version of the "yt-dlp" video downloader:
MergeOutputFormat: fs.VideoMp4.String(),
RemuxVideo: fs.VideoMp4.String(),
// Alternative codec sorting format to prioritize H264/AVC:
// vcodec:h264>av01>h265>vp9.2>vp9>h263,acodec:m4a>mp4a>aac>mp3>mp3>ac3>dts
SortingFormat: "lang,quality,res,fps,codec:avc:m4a,channels,size,br,asr,proto,ext,hasaud,source,id",
}
result, err := dl.NewMetadata(context.Background(), sourceUrl.String(), opt)
if err != nil {
return err
@@ -130,14 +138,22 @@ func downloadAction(ctx *cli.Context) error {
// Download the first video and embed its metadata,
// see https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#format-selection-examples.
downloadResult, err := result.DownloadWithOptions(context.Background(), ytdl.DownloadOptions{
// Filter: "bv*+ba/b",
downloadResult, err := result.DownloadWithOptions(context.Background(), dl.DownloadOptions{
// TODO: While this may work with a future version of the "yt-dlp" video downloader,
// it is currently not possible to properly download videos with separate video and
// audio streams when piping the output to stdout. For now, the following Filter
// will download the best combined video and audio content (see docs for details).
Filter: "best",
// Alternative filters for combining the best video and audio streams:
// Filter: "bestvideo*+bestaudio/best",
// Filter: "best/bestvideo+bestaudio",
DownloadAudioOnly: false,
EmbedMetadata: true,
EmbedSubs: false,
ForceOverwrites: true,
ForceOverwrites: false,
DisableCaching: false,
PlaylistIndex: 1,
// Download the first video if multiple videos are available:
PlaylistIndex: 1,
})
// Check if download was successful.
@@ -159,6 +175,44 @@ func downloadAction(ctx *cli.Context) error {
}
file.Close()
// TODO: The remux command flags currently don't seem to have an effect when piping the output to stdout,
// so this command will manually remux the downloaded file with ffmpeg. This ensures that the file is a
// valid MP4 that can be played. It also adds metadata in the same step.
remuxOpt := encode.NewRemuxOptions(conf.FFmpegBin(), fs.VideoMp4, false)
if title := clean.Name(result.Info.Title); title != "" {
remuxOpt.Title = title
} else if title = clean.Name(result.Info.AltTitle); title != "" {
remuxOpt.Title = title
}
if desc := strings.TrimSpace(result.Info.Description); desc != "" {
remuxOpt.Description = desc
}
if u := strings.TrimSpace(sourceUrl.String()); u != "" {
remuxOpt.Comment = u
}
if author := clean.Name(result.Info.Artist); author != "" {
remuxOpt.Author = author
} else if author = clean.Name(result.Info.AlbumArtist); author != "" {
remuxOpt.Author = author
} else if author = clean.Name(result.Info.Creator); author != "" {
remuxOpt.Author = author
} else if author = clean.Name(result.Info.License); author != "" {
remuxOpt.Author = author
}
if result.Info.Timestamp > 1 {
sec, dec := math.Modf(result.Info.Timestamp)
remuxOpt.Created = time.Unix(int64(sec), int64(dec*(1e9)))
}
if remuxErr := ffmpeg.RemuxFile(downloadFilePath, "", remuxOpt); remuxErr != nil {
return remuxErr
}
}
log.Infof("importing %s to %s", mediaType, clean.Log(filepath.Join(conf.OriginalsPath(), destFolder)))

View File

@@ -51,6 +51,7 @@ import (
"github.com/photoprism/photoprism/internal/config/ttl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/mutex"
"github.com/photoprism/photoprism/internal/photoprism/dl"
"github.com/photoprism/photoprism/internal/service/hub"
"github.com/photoprism/photoprism/internal/service/hub/places"
"github.com/photoprism/photoprism/internal/thumb"
@@ -278,6 +279,11 @@ func (c *Config) Propagate() {
thumb.CachePublic = c.HttpCachePublic()
initThumbs()
// Configure video download package.
dl.YtDlpBin = c.YtDlpBin()
dl.FFmpegBin = c.FFmpegBin()
dl.FFprobeBin = c.FFprobeBin()
// Configure computer vision package.
vision.AssetsPath = c.AssetsPath()
vision.FaceNetModelPath = c.FaceNetModelPath()

View File

@@ -2,6 +2,7 @@ package config
import (
"fmt"
"path/filepath"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
"github.com/photoprism/photoprism/internal/thumb"
@@ -14,6 +15,20 @@ func (c *Config) FFmpegBin() string {
return FindBin(c.options.FFmpegBin, encode.FFmpegBin)
}
// FFprobeBin returns the ffprobe executable file name.
func (c *Config) FFprobeBin() string {
if ffmpegBin := c.FFmpegBin(); ffmpegBin != "" && fs.FileExistsNotEmpty(ffmpegBin) {
return FindBin(filepath.Join(filepath.Dir(ffmpegBin), encode.FFprobeBin), encode.FFprobeBin)
}
return FindBin(encode.FFprobeBin)
}
// YtDlpBin returns the name of the video download executable file, if installed.
func (c *Config) YtDlpBin() string {
return FindBin("yt-dlp", "yt-dl", "youtube-dl", "dl")
}
// FFmpegEnabled checks if FFmpeg is enabled for video transcoding.
func (c *Config) FFmpegEnabled() bool {
return !c.DisableFFmpeg()

View File

@@ -99,7 +99,6 @@ func TestConfig_UserUploadPath(t *testing.T) {
func TestConfig_SidecarPathIsAbs(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, true, c.SidecarPathIsAbs())
c.options.SidecarPath = ".photoprism"
assert.Equal(t, false, c.SidecarPathIsAbs())
@@ -107,16 +106,24 @@ func TestConfig_SidecarPathIsAbs(t *testing.T) {
func TestConfig_SidecarWritable(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, true, c.SidecarWritable())
}
func TestConfig_FFmpegBin(t *testing.T) {
c := NewConfig(CliTestContext())
assert.True(t, strings.Contains(c.FFmpegBin(), "/bin/ffmpeg"))
}
func TestConfig_FFprobeBin(t *testing.T) {
c := NewConfig(CliTestContext())
assert.True(t, strings.Contains(c.FFprobeBin(), "/bin/ffprobe"))
}
func TestConfig_YtDlpBin(t *testing.T) {
c := NewConfig(CliTestContext())
assert.True(t, strings.Contains(c.YtDlpBin(), "/bin/yt-dlp"))
}
func TestConfig_TempPath(t *testing.T) {
c := NewConfig(CliTestContext())

View File

@@ -8,23 +8,24 @@ import (
// TranscodeToAvcCmd returns the FFmpeg command for hardware-accelerated transcoding to MPEG-4 AVC.
func TranscodeToAvcCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
// ffmpeg -hide_banner -h encoder=h264_videotoolbox
return exec.Command(
opt.Bin,
"-hide_banner", "-y",
"-hide_banner",
"-y",
"-strict", "-2",
"-i", srcName,
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-ignore_unknown",
"-c:a", "aac",
"-vf", opt.VideoFilter(encode.FormatYUV420P),
"-profile", "high",
"-level", "51",
"-r", "30",
"-q:v", opt.QvQuality(),
"-f", "mp4",
"-movflags", opt.MovFlags,
"-map_metadata", opt.MapMetadata,
destName,
)
}

View File

@@ -6,20 +6,22 @@ import "os/exec"
func TranscodeToAvcCmd(srcName, destName string, opt Options) *exec.Cmd {
return exec.Command(
opt.Bin,
"-hide_banner", "-y",
"-hide_banner",
"-y",
"-strict", "-2",
"-i", srcName,
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-ignore_unknown",
"-c:a", "aac",
"-preset", opt.Preset,
"-vf", opt.VideoFilter(FormatYUV420P),
"-max_muxing_queue_size", "1024",
"-r", "30",
"-crf", opt.CrfQuality(),
"-f", "mp4",
"-movflags", opt.MovFlags,
"-map_metadata", opt.MapMetadata,
destName,
)
}

View File

@@ -16,6 +16,7 @@ const (
// Default video and audio track mapping.
const (
DefaultMapVideo = "0:v:0"
DefaultMapAudio = "0:a:0?"
DefaultMapVideo = "0:v:0"
DefaultMapAudio = "0:a:0?"
DefaultMapMetadata = "0"
)

View File

@@ -6,4 +6,4 @@ package encode
// - https://cloudinary.com/glossary/fragmented-mp4
// - https://medium.com/@vlad.pbr/in-browser-live-video-using-fragmented-mp4-3aedb600a07e
// - https://github.com/video-dev/hls.js?tab=readme-ov-file#features
var MovFlags = "frag_keyframe+empty_moov+default_base_moof+faststart"
var MovFlags = "use_metadata_tags+faststart"

View File

@@ -3,21 +3,31 @@ package encode
import (
"fmt"
"time"
"github.com/photoprism/photoprism/pkg/fs"
)
// Options represents FFmpeg encoding options.
type Options struct {
Bin string // FFmpeg binary filename, e.g. /usr/bin/ffmpeg
Encoder Encoder // Supported FFmpeg output Encoder
SizeLimit int // Maximum width and height of the output video file in pixels.
Quality int // See https://ffmpeg.org/ffmpeg-codecs.html
Preset string // See https://trac.ffmpeg.org/wiki/Encode/H.264#Preset
Device string // See https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
MapVideo string // See https://trac.ffmpeg.org/wiki/Map#Videostreamsonly
MapAudio string // See https://trac.ffmpeg.org/wiki/Map#Audiostreamsonly
TimeOffset string // See https://trac.ffmpeg.org/wiki/Seeking and https://ffmpeg.org/ffmpeg-utils.html#time-duration-syntax
Duration time.Duration // See https://ffmpeg.org/ffmpeg.html#Main-options
MovFlags string
Bin string // FFmpeg binary filename, e.g. /usr/bin/ffmpeg
Container fs.Type // Multimedia Container File Format
Encoder Encoder // Supported FFmpeg output Encoder
SizeLimit int // Maximum width and height of the output video file in pixels.
Quality int // See https://ffmpeg.org/ffmpeg-codecs.html
Preset string // See https://trac.ffmpeg.org/wiki/Encode/H.264#Preset
Device string // See https://trac.ffmpeg.org/wiki/Limiting%20the%20output%20bitrate
MapVideo string // See https://trac.ffmpeg.org/wiki/Map#Videostreamsonly
MapAudio string // See https://trac.ffmpeg.org/wiki/Map#Audiostreamsonly
MapMetadata string // See https://ffmpeg.org/ffmpeg.html
TimeOffset string // See https://trac.ffmpeg.org/wiki/Seeking and https://ffmpeg.org/ffmpeg-utils.html#time-duration-syntax
Duration time.Duration // See https://ffmpeg.org/ffmpeg.html#Main-options
MovFlags string
Title string
Description string
Comment string
Author string
Created time.Time
Force bool
}
// NewVideoOptions creates and returns new FFmpeg video transcoding options.
@@ -57,15 +67,38 @@ func NewVideoOptions(ffmpegBin string, encoder Encoder, sizeLimit, quality int,
}
return Options{
Bin: ffmpegBin,
Encoder: encoder,
SizeLimit: sizeLimit,
Quality: quality,
Preset: preset,
Device: device,
MapVideo: mapVideo,
MapAudio: mapAudio,
MovFlags: MovFlags,
Bin: ffmpegBin,
Container: fs.VideoMp4,
Encoder: encoder,
SizeLimit: sizeLimit,
Quality: quality,
Preset: preset,
Device: device,
MapVideo: mapVideo,
MapAudio: mapAudio,
MapMetadata: DefaultMapMetadata,
MovFlags: MovFlags,
}
}
// NewRemuxOptions creates and returns new video remux options.
func NewRemuxOptions(ffmpegBin string, container fs.Type, force bool) Options {
if ffmpegBin == "" {
ffmpegBin = FFmpegBin
}
if container == "" {
container = fs.VideoMp4
}
return Options{
Bin: ffmpegBin,
Container: fs.VideoMp4,
MapVideo: DefaultMapVideo,
MapAudio: DefaultMapAudio,
MapMetadata: DefaultMapMetadata,
MovFlags: MovFlags,
Force: force,
}
}

View File

@@ -12,7 +12,8 @@ func TranscodeToAvcCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
if opt.Device != "" {
return exec.Command(
opt.Bin,
"-hide_banner", "-y",
"-hide_banner",
"-y",
"-strict", "-2",
"-hwaccel", "qsv",
"-hwaccel_device", opt.Device,
@@ -23,17 +24,19 @@ func TranscodeToAvcCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-ignore_unknown",
"-preset", opt.Preset,
"-r", "30",
"-global_quality", opt.GlobalQuality(),
"-f", "mp4",
"-movflags", opt.MovFlags,
"-map_metadata", opt.MapMetadata,
destName,
)
} else {
return exec.Command(
opt.Bin,
"-hide_banner", "-y",
"-hide_banner",
"-y",
"-strict", "-2",
"-hwaccel", "qsv",
"-hwaccel_output_format", "qsv",
@@ -43,11 +46,12 @@ func TranscodeToAvcCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-ignore_unknown",
"-preset", opt.Preset,
"-r", "30",
"-global_quality", opt.GlobalQuality(),
"-f", "mp4",
"-movflags", opt.MovFlags,
"-map_metadata", opt.MapMetadata,
destName,
)
}

View File

@@ -11,7 +11,8 @@ func TranscodeToAvcCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
// ffmpeg -hide_banner -h encoder=h264_nvenc
return exec.Command(
opt.Bin,
"-hide_banner", "-y",
"-hide_banner",
"-y",
"-strict", "-2",
"-hwaccel", "auto",
"-i", srcName,
@@ -19,6 +20,7 @@ func TranscodeToAvcCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-ignore_unknown",
"-c:a", "aac",
"-preset", opt.Preset,
"-pixel_format", "yuv420p",
@@ -27,12 +29,12 @@ func TranscodeToAvcCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
"-rc:v", "constqp",
"-cq", opt.CqQuality(),
"-tune", "2",
"-r", "30",
"-profile:v", "1",
"-level:v", "auto",
"-coder:v", "1",
"-f", "mp4",
"-movflags", opt.MovFlags,
"-map_metadata", opt.MapMetadata,
destName,
)
}

204
internal/ffmpeg/remux.go Normal file
View File

@@ -0,0 +1,204 @@
package ffmpeg
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
// RemuxFile changes the file format to the specified container as needed.
func RemuxFile(videoFilePath, destFilePath string, opt encode.Options) error {
// Return if destination file already exists and force option is not set.
if !opt.Force && fs.FileExistsNotEmpty(destFilePath) {
return nil
}
// Error if source file does not exist or is empty.
if !fs.FileExistsNotEmpty(videoFilePath) {
return errors.New("invalid video file path")
}
// Use MP4 as default container format.
if opt.Container == "" {
opt.Container = fs.ExtMp4
}
videoBaseName := filepath.Base(videoFilePath)
if destFilePath == "" {
destFilePath = fs.StripKnownExt(videoFilePath) + opt.Container.DefaultExt()
}
destFileBase := filepath.Base(destFilePath)
destPathName := filepath.Dir(destFilePath)
tempBaseName := "." + fs.StripKnownExt(clean.FileName(videoBaseName)) + opt.Container.DefaultExt()
tempFilePath := filepath.Join(destPathName, tempBaseName)
cmd, err := RemuxCmd(videoFilePath, tempFilePath, opt)
// Return if an error occurred.
if err != nil {
log.Error(err)
return err
}
// Check if target file already exists.
if fs.FileExists(tempFilePath) {
if !opt.Force {
return fmt.Errorf("temp file %s already exists", clean.Log(tempBaseName))
} else if err = os.Remove(tempFilePath); err != nil {
return fmt.Errorf("%s (remove temp file)", err)
}
log.Infof("ffmpeg: replacing temp file %s", clean.Log(tempBaseName))
}
// Fetch command output.
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
cmd.Env = append(cmd.Env, []string{
fmt.Sprintf("HOME=%s", tempFilePath),
}...)
log.Infof("ffmpeg: changing container format of %s to %s", clean.Log(videoBaseName), opt.Container)
// Log exact command for debugging in trace mode.
log.Trace(cmd.String())
// Transcode source media file to AVC.
start := time.Now()
if err = cmd.Run(); err != nil {
if stderr.String() != "" {
err = errors.New(stderr.String())
}
// Log ffmpeg output for debugging.
if err.Error() != "" {
log.Debug(err)
}
// Log filename and transcoding time.
log.Warnf("ffmpeg: failed to convert %s [%s]", clean.Log(videoBaseName), time.Since(start))
// Remove broken video file.
if !fs.FileExists(tempFilePath) {
// Do nothing.
} else if err = os.Remove(tempFilePath); err != nil {
return fmt.Errorf("failed to remove temp file %s (%s)", clean.Log(tempBaseName), err)
}
return err
}
// Abort if destination file is missing or empty.
if !fs.FileExistsNotEmpty(tempFilePath) {
_ = os.Remove(tempFilePath)
return fmt.Errorf("failed change container format of %s [%s]", clean.Log(videoBaseName), time.Since(start))
}
if !fs.FileExists(destFilePath) {
// Do nothing.
} else if err = os.Remove(destFilePath); err != nil {
_ = os.Remove(tempFilePath)
return fmt.Errorf("failed to remove %s (%s)", clean.Log(destFileBase), err)
}
if err = os.Rename(tempFilePath, destFilePath); err != nil {
return fmt.Errorf("failed to rename %s to %s (%s)", clean.Log(tempBaseName), clean.Log(destFileBase), err)
}
// Log filename and remux time.
if videoBaseName != destFileBase {
log.Infof("ffmpeg: converted %s to %s [%s]", clean.Log(videoBaseName), clean.Log(destFileBase), time.Since(start))
} else {
log.Infof("ffmpeg: converted %s to %s [%s]", clean.Log(videoBaseName), opt.Container.String(), time.Since(start))
}
return nil
}
// RemuxCmd returns the FFmpeg command for transferring content from one container format to another without altering the original video or audio stream.
func RemuxCmd(srcName, destName string, opt encode.Options) (cmd *exec.Cmd, err error) {
if srcName == "" {
return nil, fmt.Errorf("empty source filename")
} else if !fs.FileExistsNotEmpty(srcName) {
return nil, fmt.Errorf("source file is empty or missing")
} else if destName == "" {
return nil, fmt.Errorf("empty destination filename")
} else if srcName == destName {
return nil, fmt.Errorf("source and destination filenames must be different")
}
// Use the default binary name if no name is specified.
if opt.Bin == "" {
opt.Bin = encode.FFmpegBin
}
// Compose "ffmpeg" command flags:
flags := []string{
"-hide_banner",
"-y",
"-strict", "-2",
"-avoid_negative_ts", "make_non_negative",
"-i", srcName,
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-dn", // Exclude data streams such as subtitles, timecode tracks, or camera motion data from the output file.
"-ignore_unknown",
"-codec", "copy",
"-f", opt.Container.String(),
}
// Append format specific "ffmpeg" command flags.
switch opt.Container {
case fs.VideoMp4:
// Ensure MP4 compatibility:
flags = append(flags,
"-movflags", opt.MovFlags,
"-map_metadata", opt.MapMetadata, // Copy existing video metadata.
)
// If specified, add the following metadata:
if title := clean.Name(opt.Title); title != "" {
flags = append(flags, "-metadata", fmt.Sprintf(`title=%s`, title))
}
if desc := strings.TrimSpace(opt.Description); desc != "" {
flags = append(flags, "-metadata", fmt.Sprintf(`description=%s`, desc))
}
if comment := strings.TrimSpace(opt.Comment); comment != "" {
flags = append(flags, "-metadata", fmt.Sprintf(`comment=%s`, comment))
}
if author := clean.Name(opt.Author); author != "" {
flags = append(flags, "-metadata", fmt.Sprintf(`author=%s`, author))
}
if !opt.Created.IsZero() {
flags = append(flags, "-metadata", fmt.Sprintf(`creation_time=%s`, opt.Created.Format(time.DateTime)))
}
}
// Set the destination file name as the last command flag.
flags = append(flags, destName)
cmd = exec.Command(
opt.Bin,
flags...,
)
return cmd, nil
}

View File

@@ -0,0 +1,100 @@
package ffmpeg
import (
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestRemuxFile(t *testing.T) {
ffmpegBin := "/usr/bin/ffmpeg"
t.Run("NoFilePath", func(t *testing.T) {
opt := encode.NewRemuxOptions(ffmpegBin, fs.VideoMp4, false)
err := RemuxFile("", "", opt)
assert.Equal(t, "invalid video file path", err.Error())
})
t.Run("Mp4", func(t *testing.T) {
opt := encode.NewRemuxOptions(ffmpegBin, fs.VideoMp4, false)
// QuickTime MOV container with HVC1 (HEVC) codec.
origName := fs.Abs("./testdata/30fps.mov")
srcName := fs.Abs("./testdata/30fps.remux-file.mov")
tmpName := fs.Abs("./testdata/.30fps.remux-file.mp4")
destName := fs.Abs("./testdata/30fps.remux-file.avc")
_ = os.Remove(srcName)
_ = os.Remove(tmpName)
_ = os.Remove(destName)
defer func() {
_ = os.Remove(srcName)
_ = os.Remove(tmpName)
_ = os.Remove(destName)
}()
if err := fs.Copy(origName, srcName); err != nil {
t.Fatal(err)
}
if err := RemuxFile(srcName, destName, opt); err != nil {
t.Fatal(err)
}
assert.FileExists(t, srcName)
assert.NoFileExists(t, tmpName)
assert.FileExists(t, destName)
})
}
func TestRemuxCmd(t *testing.T) {
ffmpegBin := "/usr/bin/ffmpeg"
t.Run("NoSrcName", func(t *testing.T) {
opt := encode.NewRemuxOptions(ffmpegBin, fs.VideoMp4, false)
_, err := RemuxCmd("", "", opt)
assert.Equal(t, "empty source filename", err.Error())
})
t.Run("Mp4", func(t *testing.T) {
opt := encode.NewRemuxOptions(ffmpegBin, fs.VideoMp4, false)
// QuickTime MOV container with HVC1 (HEVC) codec.
origName := fs.Abs("./testdata/30fps.mov")
srcName := fs.Abs("./testdata/30fps.remux-cmd.mov")
destName := fs.Abs("./testdata/30fps.remux-cmd.mp4")
_ = os.Remove(srcName)
_ = os.Remove(destName)
defer func() {
_ = os.Remove(srcName)
_ = os.Remove(destName)
}()
if err := fs.Copy(origName, srcName); err != nil {
t.Fatal(err)
}
cmd, err := RemuxCmd(srcName, destName, opt)
if err != nil {
t.Fatal(err)
}
cmdStr := cmd.String()
cmdStr = strings.Replace(cmdStr, srcName, "SRC", 1)
cmdStr = strings.Replace(cmdStr, destName, "DEST", 1)
assert.Equal(t, "/usr/bin/ffmpeg -hide_banner -y -strict -2 -avoid_negative_ts make_non_negative -i SRC -map 0:v:0 -map 0:a:0? -dn -ignore_unknown -codec copy -f mp4 -movflags use_metadata_tags+faststart -map_metadata 0 DEST", cmdStr)
})
}

View File

@@ -33,9 +33,11 @@ func TranscodeCmd(srcName, destName string, opt encode.Options) (cmd *exec.Cmd,
if fs.TypeAnimated[fs.FileType(srcName)] != "" {
cmd = exec.Command(
opt.Bin,
"-hide_banner", "-y",
"-hide_banner",
"-y",
"-strict", "-2",
"-i", srcName,
"-ignore_unknown",
"-pix_fmt", encode.FormatYUV420P.String(),
"-vf", "scale='trunc(iw/2)*2:trunc(ih/2)*2'",
"-f", "mp4",

View File

@@ -34,7 +34,7 @@ func TestTranscodeCmd(t *testing.T) {
t.Fatal(err)
}
assert.Contains(t, r.String(), "bin/ffmpeg -hide_banner -y -strict -2 -i VID123.gif -pix_fmt yuv420p -vf scale='trunc(iw/2)*2:trunc(ih/2)*2' -f mp4 -movflags +faststart VID123.gif.avc")
assert.Contains(t, r.String(), "bin/ffmpeg -hide_banner -y -strict -2 -i VID123.gif -ignore_unknown -pix_fmt yuv420p -vf scale='trunc(iw/2)*2:trunc(ih/2)*2' -f mp4 -movflags +faststart VID123.gif.avc")
})
t.Run("VP9toAVC", func(t *testing.T) {
opt := encode.NewVideoOptions(ffmpegBin, encode.SoftwareAvc, 1500, encode.DefaultQuality, encode.PresetFast, "", "", "")
@@ -52,7 +52,7 @@ func TestTranscodeCmd(t *testing.T) {
cmdStr = strings.Replace(cmdStr, srcName, "SRC", 1)
cmdStr = strings.Replace(cmdStr, destName, "DEST", 1)
assert.Equal(t, "/usr/bin/ffmpeg -hide_banner -y -strict -2 -i SRC -c:v libx264 -map 0:v:0 -map 0:a:0? -c:a aac -preset fast -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -max_muxing_queue_size 1024 -r 30 -crf 25 -f mp4 -movflags frag_keyframe+empty_moov+default_base_moof+faststart DEST", cmdStr)
assert.Equal(t, "/usr/bin/ffmpeg -hide_banner -y -strict -2 -i SRC -c:v libx264 -map 0:v:0 -map 0:a:0? -ignore_unknown -c:a aac -preset fast -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -max_muxing_queue_size 1024 -crf 25 -f mp4 -movflags use_metadata_tags+faststart -map_metadata 0 DEST", cmdStr)
// Run generated command to test software transcoding.
RunCommandTest(t, opt.Encoder, srcName, destName, cmd, true)
@@ -73,7 +73,7 @@ func TestTranscodeCmd(t *testing.T) {
cmdStr = strings.Replace(cmdStr, srcName, "SRC", 1)
cmdStr = strings.Replace(cmdStr, destName, "DEST", 1)
assert.Equal(t, "/usr/bin/ffmpeg -hide_banner -y -strict -2 -hwaccel vaapi -i SRC -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=nv12,hwupload -c:v h264_vaapi -map 0:v:0 -map 0:a:0? -r 30 -qp 25 -f mp4 -movflags frag_keyframe+empty_moov+default_base_moof+faststart DEST", cmdStr)
assert.Equal(t, "/usr/bin/ffmpeg -hide_banner -y -strict -2 -hwaccel vaapi -i SRC -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=nv12,hwupload -c:v h264_vaapi -map 0:v:0 -map 0:a:0? -ignore_unknown -qp 25 -f mp4 -movflags use_metadata_tags+faststart -map_metadata 0 DEST", cmdStr)
// This transcoding test requires a supported hardware device that is properly configured:
if os.Getenv("PHOTOPRISM_FFMPEG_ENCODER") == "vaapi" {
@@ -97,7 +97,7 @@ func TestTranscodeCmd(t *testing.T) {
cmdStr = strings.Replace(cmdStr, srcName, "SRC", 1)
cmdStr = strings.Replace(cmdStr, destName, "DEST", 1)
assert.Equal(t, "/usr/bin/ffmpeg -hide_banner -y -strict -2 -hwaccel qsv -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format qsv -i SRC -c:a aac -vf scale_qsv=w='if(gte(iw,ih), min(1500, iw), -1)':h='if(gte(iw,ih), -1, min(1500, ih))':format=nv12 -c:v h264_qsv -map 0:v:0 -map 0:a:0? -preset fast -r 30 -global_quality 25 -f mp4 -movflags frag_keyframe+empty_moov+default_base_moof+faststart DEST", cmdStr)
assert.Equal(t, "/usr/bin/ffmpeg -hide_banner -y -strict -2 -hwaccel qsv -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format qsv -i SRC -c:a aac -vf scale_qsv=w='if(gte(iw,ih), min(1500, iw), -1)':h='if(gte(iw,ih), -1, min(1500, ih))':format=nv12 -c:v h264_qsv -map 0:v:0 -map 0:a:0? -ignore_unknown -preset fast -global_quality 25 -f mp4 -movflags use_metadata_tags+faststart -map_metadata 0 DEST", cmdStr)
// This transcoding test requires a supported hardware device that is properly configured:
if os.Getenv("PHOTOPRISM_FFMPEG_ENCODER") == "intel" {
@@ -120,7 +120,7 @@ func TestTranscodeCmd(t *testing.T) {
cmdStr = strings.Replace(cmdStr, srcName, "SRC", 1)
cmdStr = strings.Replace(cmdStr, destName, "DEST", 1)
assert.Equal(t, "/usr/bin/ffmpeg -hide_banner -y -strict -2 -hwaccel qsv -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format qsv -i SRC -c:a aac -vf scale_qsv=w='if(gte(iw,ih), min(1500, iw), -1)':h='if(gte(iw,ih), -1, min(1500, ih))':format=nv12 -c:v h264_qsv -map 0:v:0 -map 0:a:0? -preset fast -r 30 -global_quality 25 -f mp4 -movflags frag_keyframe+empty_moov+default_base_moof+faststart DEST", cmdStr)
assert.Equal(t, "/usr/bin/ffmpeg -hide_banner -y -strict -2 -hwaccel qsv -hwaccel_device /dev/dri/renderD128 -hwaccel_output_format qsv -i SRC -c:a aac -vf scale_qsv=w='if(gte(iw,ih), min(1500, iw), -1)':h='if(gte(iw,ih), -1, min(1500, ih))':format=nv12 -c:v h264_qsv -map 0:v:0 -map 0:a:0? -ignore_unknown -preset fast -global_quality 25 -f mp4 -movflags use_metadata_tags+faststart -map_metadata 0 DEST", cmdStr)
// This transcoding test requires a supported hardware device that is properly configured:
if os.Getenv("PHOTOPRISM_FFMPEG_ENCODER") == "intel" {
@@ -144,7 +144,7 @@ func TestTranscodeCmd(t *testing.T) {
cmdStr = strings.Replace(cmdStr, srcName, "SRC", 1)
cmdStr = strings.Replace(cmdStr, destName, "DEST", 1)
assert.Equal(t, "/usr/bin/ffmpeg -hide_banner -y -strict -2 -hwaccel auto -i SRC -pix_fmt yuv420p -c:v h264_nvenc -map 0:v:0 -map 0:a:0? -c:a aac -preset fast -pixel_format yuv420p -gpu any -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -rc:v constqp -cq 25 -tune 2 -r 30 -profile:v 1 -level:v auto -coder:v 1 -f mp4 -movflags frag_keyframe+empty_moov+default_base_moof+faststart DEST", cmdStr)
assert.Equal(t, "/usr/bin/ffmpeg -hide_banner -y -strict -2 -hwaccel auto -i SRC -pix_fmt yuv420p -c:v h264_nvenc -map 0:v:0 -map 0:a:0? -ignore_unknown -c:a aac -preset fast -pixel_format yuv420p -gpu any -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -rc:v constqp -cq 25 -tune 2 -profile:v 1 -level:v auto -coder:v 1 -f mp4 -movflags use_metadata_tags+faststart -map_metadata 0 DEST", cmdStr)
// This transcoding test requires a supported hardware device that is properly configured:
if os.Getenv("PHOTOPRISM_FFMPEG_ENCODER") == "nvidia" {
@@ -167,7 +167,7 @@ func TestTranscodeCmd(t *testing.T) {
cmdStr = strings.Replace(cmdStr, srcName, "SRC", 1)
cmdStr = strings.Replace(cmdStr, destName, "DEST", 1)
assert.Equal(t, "/usr/bin/ffmpeg -hide_banner -y -strict -2 -hwaccel auto -i SRC -pix_fmt yuv420p -c:v h264_nvenc -map 0:v:0 -map 0:a:0? -c:a aac -preset fast -pixel_format yuv420p -gpu any -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -rc:v constqp -cq 25 -tune 2 -r 30 -profile:v 1 -level:v auto -coder:v 1 -f mp4 -movflags frag_keyframe+empty_moov+default_base_moof+faststart DEST", cmdStr)
assert.Equal(t, "/usr/bin/ffmpeg -hide_banner -y -strict -2 -hwaccel auto -i SRC -pix_fmt yuv420p -c:v h264_nvenc -map 0:v:0 -map 0:a:0? -ignore_unknown -c:a aac -preset fast -pixel_format yuv420p -gpu any -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -rc:v constqp -cq 25 -tune 2 -profile:v 1 -level:v auto -coder:v 1 -f mp4 -movflags use_metadata_tags+faststart -map_metadata 0 DEST", cmdStr)
// This transcoding test requires a supported hardware device that is properly configured:
if os.Getenv("PHOTOPRISM_FFMPEG_ENCODER") == "nvidia" {
@@ -182,7 +182,7 @@ func TestTranscodeCmd(t *testing.T) {
t.Fatal(err)
}
assert.Contains(t, r.String(), "ffmpeg -hide_banner -y -strict -2 -i VID123.mov -c:v h264_videotoolbox -map 0:v:0 -map 0:a:0? -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -profile high -level 51 -r 30 -q:v 50 -f mp4 -movflags frag_keyframe+empty_moov+default_base_moof+faststart VID123.mov.avc")
assert.Contains(t, r.String(), "ffmpeg -hide_banner -y -strict -2 -i VID123.mov -c:v h264_videotoolbox -map 0:v:0 -map 0:a:0? -ignore_unknown -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -profile high -level 51 -q:v 50 -f mp4 -movflags use_metadata_tags+faststart -map_metadata 0 VID123.mov.avc")
})
t.Run("Video4Linux", func(t *testing.T) {
opt := encode.NewVideoOptions("", encode.V4LAvc, 1500, encode.DefaultQuality, encode.PresetFast, "", "", "")
@@ -192,6 +192,6 @@ func TestTranscodeCmd(t *testing.T) {
t.Fatal(err)
}
assert.Contains(t, r.String(), "ffmpeg -hide_banner -y -strict -2 -i VID123.mov -c:v h264_v4l2m2m -map 0:v:0 -map 0:a:0? -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -num_output_buffers 72 -num_capture_buffers 64 -max_muxing_queue_size 1024 -f mp4 -movflags frag_keyframe+empty_moov+default_base_moof+faststart VID123.mov.avc")
assert.Contains(t, r.String(), "ffmpeg -hide_banner -y -strict -2 -i VID123.mov -c:v h264_v4l2m2m -map 0:v:0 -map 0:a:0? -ignore_unknown -c:a aac -vf scale='if(gte(iw,ih), min(1500, iw), -2):if(gte(iw,ih), -2, min(1500, ih))',format=yuv420p -num_output_buffers 72 -num_capture_buffers 64 -max_muxing_queue_size 1024 -f mp4 -movflags use_metadata_tags+faststart -map_metadata 0 VID123.mov.avc")
})
}

View File

@@ -11,12 +11,14 @@ func TranscodeToAvcCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
// ffmpeg -hide_banner -h encoder=h264_v4l2m2m
return exec.Command(
opt.Bin,
"-hide_banner", "-y",
"-hide_banner",
"-y",
"-strict", "-2",
"-i", srcName,
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-ignore_unknown",
"-c:a", "aac",
"-vf", opt.VideoFilter(encode.FormatYUV420P),
"-num_output_buffers", "72",
@@ -24,6 +26,7 @@ func TranscodeToAvcCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
"-max_muxing_queue_size", "1024",
"-f", "mp4",
"-movflags", opt.MovFlags,
"-map_metadata", opt.MapMetadata,
destName,
)
}

View File

@@ -11,7 +11,8 @@ func TranscodeToAvcCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
if opt.Device != "" {
return exec.Command(
opt.Bin,
"-hide_banner", "-y",
"-hide_banner",
"-y",
"-strict", "-2",
"-hwaccel", "vaapi",
"-hwaccel_device", opt.Device,
@@ -21,16 +22,18 @@ func TranscodeToAvcCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-r", "30",
"-ignore_unknown",
"-qp", opt.QpQuality(),
"-f", "mp4",
"-movflags", opt.MovFlags,
"-map_metadata", opt.MapMetadata,
destName,
)
} else {
return exec.Command(
opt.Bin,
"-hide_banner", "-y",
"-hide_banner",
"-y",
"-strict", "-2",
"-hwaccel", "vaapi",
"-i", srcName,
@@ -39,10 +42,11 @@ func TranscodeToAvcCmd(srcName, destName string, opt encode.Options) *exec.Cmd {
"-c:v", opt.Encoder.String(),
"-map", opt.MapVideo,
"-map", opt.MapAudio,
"-r", "30",
"-ignore_unknown",
"-qp", opt.QpQuality(),
"-f", "mp4",
"-movflags", opt.MovFlags,
"-map_metadata", opt.MapMetadata,
destName,
)
}

View File

@@ -19,8 +19,8 @@ type Data struct {
MimeType string `meta:"MIMEType" report:"-"`
DocumentID string `meta:"ContentIdentifier,MediaGroupUUID,BurstUUID,OriginalDocumentID,DocumentID,ImageUniqueID,DigitalImageGUID"` // see https://exiftool.org/forum/index.php?topic=14874.0
InstanceID string `meta:"InstanceID,DocumentID"`
CreatedAt time.Time `meta:"SubSecCreateDate,CreationDate,CreateDate,MediaCreateDate,ContentCreateDate,TrackCreateDate"`
TakenAt time.Time `meta:"SubSecDateTimeOriginal,SubSecDateTimeCreated,DateTimeOriginal,CreationDate,DateTimeCreated,DateTime,DateTimeDigitized" xmp:"DateCreated"`
CreatedAt time.Time `meta:"SubSecCreateDate,CreationTime,CreationDate,CreateDate,MediaCreateDate,ContentCreateDate,TrackCreateDate"`
TakenAt time.Time `meta:"SubSecDateTimeOriginal,SubSecDateTimeCreated,DateTimeOriginal,CreationTime,CreationDate,DateTimeCreated,DateTime,DateTimeDigitized" xmp:"DateCreated,CreationTime"`
TakenAtLocal time.Time `meta:"SubSecDateTimeOriginal,SubSecDateTimeCreated,DateTimeOriginal,CreationDate,DateTimeCreated,DateTime,DateTimeDigitized"`
TakenGps time.Time `meta:"GPSDateTime,GPSDateStamp"`
TakenNs int `meta:"-"`

View File

@@ -10,6 +10,7 @@ import (
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media"
)
// ToJson uses exiftool to export metadata to a json file.
@@ -30,9 +31,20 @@ func (w *Convert) ToJson(f *MediaFile, force bool) (jsonName string, err error)
log.Debugf("exiftool: extracting metadata from %s", clean.Log(f.RootRelName()))
cmd := exec.Command(w.conf.ExifToolBin(), "-n", "-m", "-api", "LargeFileSupport", "-j", f.FileName())
// Command arguments.
var args []string
// Fetch command output.
// Also extract embedded metadata from videos and live photos.
if f.IsVideo() || f.MetaData().MediaType == media.Live {
args = []string{"-n", "-ee", "-m", "-api", "LargeFileSupport", "-j", f.FileName()}
} else {
args = []string{"-n", "-m", "-api", "LargeFileSupport", "-j", f.FileName()}
}
// Compose ExifTool command to run.
cmd := exec.Command(w.conf.ExifToolBin(), args...)
// Command environment, output and errors.
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
@@ -45,7 +57,7 @@ func (w *Convert) ToJson(f *MediaFile, force bool) (jsonName string, err error)
log.Trace(cmd.String())
// Run convert command.
if err := cmd.Run(); err != nil {
if err = cmd.Run(); err != nil {
if stderr.String() != "" {
return "", errors.New(stderr.String())
} else {
@@ -54,7 +66,7 @@ func (w *Convert) ToJson(f *MediaFile, force bool) (jsonName string, err error)
}
// Write output to file.
if err := os.WriteFile(jsonName, []byte(out.String()), fs.ModeFile); err != nil {
if err = os.WriteFile(jsonName, []byte(out.String()), fs.ModeFile); err != nil {
return "", err
}

View File

@@ -41,16 +41,33 @@ func (w *Convert) ToAvc(f *MediaFile, encoder encode.Encoder, noMutex, force boo
if f.IsAnimatedImage() {
avcName = fs.VideoMp4.FindFirst(f.FileName(), []string{w.conf.SidecarPath(), fs.PPHiddenPathname}, w.conf.OriginalsPath(), false)
} else {
// Convert MPEG-2 Transport Stream (M2TS) files to MPEG4 containers.
if f.IsM2TS() && w.conf.SidecarWritable() {
if mp4Name, mp4Err := fs.FileName(f.FileName(), w.conf.SidecarPath(), w.conf.OriginalsPath(), fs.ExtMp4); mp4Err != nil {
return nil, fmt.Errorf("convert: %s in %s (remux)", mp4Err, clean.Log(f.RootRelName()))
} else if mp4Err = ffmpeg.RemuxFile(f.FileName(), mp4Name, encode.NewRemuxOptions(conf.FFmpegBin(), fs.VideoMp4, false)); mp4Err != nil {
return nil, fmt.Errorf("convert: %s in %s (remux)", err, clean.Log(f.RootRelName()))
} else if mp4File, fileErr := NewMediaFile(mp4Name); mp4File == nil || fileErr != nil {
log.Warnf("convert: %s could not be converted to mp4", logFileName)
} else if jsonErr := mp4File.CreateExifToolJson(w); jsonErr != nil {
log.Warnf("convert: %s in %s (create json)", jsonErr, logFileName)
} else if jsonErr = mp4File.ReadExifToolJson(); jsonErr != nil {
log.Warnf("convert: %s in %s (read json)", jsonErr, logFileName)
} else if mp4File.MetaData().CodecAvc() {
return mp4File, nil
}
}
avcName = fs.VideoAvc.FindFirst(f.FileName(), []string{w.conf.SidecarPath(), fs.PPHiddenPathname}, w.conf.OriginalsPath(), false)
}
mediaFile, err := NewMediaFile(avcName)
// Return it if an MP4 AVC encoded video file already exists.
// Return the AVC-encoded video file if it already exists.
if mediaFile == nil || err != nil {
// Do nothing.
} else if mediaFile.IsVideo() {
// Return MP4 AVC encoded video file
// Return existing AVC file.
log.Debugf("convert: %s has already been transcoded to MPEG-4 AVC", logFileName)
return mediaFile, nil
}

View File

@@ -1,29 +1,30 @@
package ytdl
package dl
import (
"github.com/photoprism/photoprism/internal/config"
"os/exec"
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
)
var (
Bin = ""
YtDlpBin = ""
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")
// FindYtDlpBin returns the YouTube / M38U video downloader binary name.
func FindYtDlpBin() string {
if YtDlpBin == "" {
YtDlpBin, _ = exec.LookPath("yt-dlp")
}
return Bin
return YtDlpBin
}
// FindFFmpegBin returns the "ffmpeg" command binary name.
func FindFFmpegBin() string {
if FFmpegBin == "" {
FFmpegBin = config.FindBin(encode.FFmpegBin)
FFmpegBin, _ = exec.LookPath(encode.FFmpegBin)
}
return FFmpegBin
@@ -32,7 +33,7 @@ func FindFFmpegBin() string {
// FindFFprobeBin returns the "ffprobe" command binary name.
func FindFFprobeBin() string {
if FFprobeBin == "" {
FFprobeBin = config.FindBin(encode.FFprobeBin)
FFprobeBin, _ = exec.LookPath(encode.FFprobeBin)
}
return FFprobeBin

View File

@@ -1,4 +1,4 @@
package ytdl
package dl
import (
"strings"
@@ -8,7 +8,7 @@ import (
)
func TestFindBin(t *testing.T) {
assert.True(t, strings.Contains(FindBin(), "yt-dlp"), "binary filepath should contain 'yt-dlp'")
assert.True(t, strings.Contains(FindYtDlpBin(), "yt-dlp"), "binary filepath should contain 'yt-dlp'")
}
func TestFindFFmpegBin(t *testing.T) {

View File

@@ -1,5 +1,5 @@
/*
Package ytdl provides media download functionality.
Package dl provides media download functionality.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
@@ -28,7 +28,7 @@ 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
package dl
import (
"github.com/photoprism/photoprism/internal/event"

View File

@@ -1,4 +1,4 @@
package ytdl
package dl
import (
"bytes"
@@ -40,7 +40,7 @@ func TestParseInfo(t *testing.T) {
} {
t.Run(c.url, func(t *testing.T) {
ctx, cancelFn := context.WithCancel(context.Background())
ydlResult, err := New(ctx, c.url, Options{
ydlResult, err := NewMetadata(ctx, c.url, Options{
DownloadThumbnail: true,
})
if err != nil {
@@ -88,7 +88,7 @@ func TestParseInfo(t *testing.T) {
}
func TestPlaylist(t *testing.T) {
ydlResult, ydlResultErr := New(context.Background(), playlistRawURL, Options{
ydlResult, ydlResultErr := NewMetadata(context.Background(), playlistRawURL, Options{
Type: TypePlaylist,
DownloadThumbnail: false,
})
@@ -116,7 +116,7 @@ func TestPlaylist(t *testing.T) {
func TestChannel(t *testing.T) {
t.Skip("skip youtube for now")
ydlResult, ydlResultErr := New(
ydlResult, ydlResultErr := NewMetadata(
context.Background(),
channelRawURL,
Options{
@@ -150,7 +150,7 @@ func TestUnsupportedURL(t *testing.T) {
t.Skip("skipping test in short mode.")
}
_, ydlResultErr := New(context.Background(), "https://www.google.com", Options{})
_, ydlResultErr := NewMetadata(context.Background(), "https://www.google.com", Options{})
if ydlResultErr == nil {
t.Errorf("expected unsupported url")
}
@@ -165,7 +165,7 @@ 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{
ydlResult, ydlResultErr := NewMetadata(context.Background(), plRawURL, Options{
Type: TypePlaylist,
DownloadThumbnail: false,
})
@@ -184,7 +184,7 @@ func TestPlaylistWithPrivateVideo(t *testing.T) {
func TestSubtitles(t *testing.T) {
t.Skip("skip youtube for now")
ydlResult, ydlResultErr := New(
ydlResult, ydlResultErr := NewMetadata(
context.Background(),
subtitlesTestVideoRawURL,
Options{
@@ -228,7 +228,7 @@ func TestDownloadSections(t *testing.T) {
t.Errorf("failed to check ffmpeg installed: %s", err)
}
ydlResult, ydlResultErr := New(
ydlResult, ydlResultErr := NewMetadata(
context.Background(),
testVideoRawURL,
Options{
@@ -300,7 +300,7 @@ func TestErrorNotAPlaylist(t *testing.T) {
t.Skip("skipping test in short mode.")
}
_, ydlResultErr := New(context.Background(), testVideoRawURL, Options{
_, ydlResultErr := NewMetadata(context.Background(), testVideoRawURL, Options{
Type: TypePlaylist,
DownloadThumbnail: false,
})
@@ -314,7 +314,7 @@ func TestErrorNotASingleEntry(t *testing.T) {
t.Skip("skipping test in short mode.")
}
_, ydlResultErr := New(context.Background(), playlistRawURL, Options{
_, ydlResultErr := NewMetadata(context.Background(), playlistRawURL, Options{
Type: TypeSingle,
DownloadThumbnail: false,
})
@@ -329,7 +329,7 @@ func TestOptionDownloader(t *testing.T) {
t.Skip("skipping test in short mode.")
}
ydlResult, ydlResultErr := New(
ydlResult, ydlResultErr := NewMetadata(
context.Background(),
testVideoRawURL,
Options{
@@ -356,7 +356,7 @@ func TestOptionDownloader(t *testing.T) {
}
func TestInvalidOptionTypeField(t *testing.T) {
_, err := New(context.Background(), playlistRawURL, Options{
_, err := NewMetadata(context.Background(), playlistRawURL, Options{
Type: 42,
})
if err == nil {
@@ -371,7 +371,7 @@ func TestDownloadPlaylistEntry(t *testing.T) {
// Download file by specifying the playlist index
stderrBuf := &bytes.Buffer{}
r, err := New(context.Background(), playlistRawURL, Options{
r, err := NewMetadata(context.Background(), playlistRawURL, Options{
StderrFn: func(cmd *exec.Cmd) io.Writer {
return stderrBuf
},
@@ -425,7 +425,7 @@ func TestDownloadPlaylistEntry(t *testing.T) {
// 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{
r, err = NewMetadata(context.Background(), url, Options{
StderrFn: func(cmd *exec.Cmd) io.Writer {
return stderrBuf
},
@@ -478,7 +478,7 @@ func TestDownloadPlaylistEntry(t *testing.T) {
func TestFormatDownloadError(t *testing.T) {
t.Skip("test URL broken")
ydl, ydlErr := New(
ydl, ydlErr := NewMetadata(
context.Background(),
"https://www.reddit.com/r/newsbabes/s/92rflI0EB0",
Options{},

View File

@@ -1,4 +1,4 @@
package ytdl
package dl
import (
"context"
@@ -13,7 +13,7 @@ func Download(
filter string,
) (*DownloadResult, error) {
options.noInfoDownload = true
d, err := New(ctx, rawURL, options)
d, err := NewMetadata(ctx, rawURL, options)
if err != nil {
return nil, err
}

View File

@@ -1,4 +1,4 @@
package ytdl
package dl
import (
"bytes"
@@ -15,7 +15,7 @@ func TestDownload(t *testing.T) {
}
stderrBuf := &bytes.Buffer{}
r, err := New(context.Background(), testVideoRawURL, Options{
r, err := NewMetadata(context.Background(), testVideoRawURL, Options{
StderrFn: func(cmd *exec.Cmd) io.Writer {
return stderrBuf
},

View File

@@ -1,4 +1,4 @@
package ytdl
package dl
import (
"errors"

View File

@@ -1,4 +1,4 @@
package ytdl
package dl
import (
"fmt"

View File

@@ -1,4 +1,4 @@
package ytdl
package dl
import (
"bufio"
@@ -107,7 +107,7 @@ func infoFromURL(
) (info Info, rawJSON []byte, err error) {
cmd := exec.CommandContext(
ctx,
FindBin(),
FindYtDlpBin(),
// see comment below about ignoring errors for playlists
"--ignore-errors",
// TODO: deprecated in yt-dlp?

View File

@@ -0,0 +1,38 @@
package dl
import (
"context"
)
// Metadata represents information and options related to a video download URL.
type Metadata struct {
Info Info
RawURL string
RawJSON []byte // saved raw JSON. Used later when downloading
Options Options // options passed to NewMetadata
}
// NewMetadata downloads metadata for URL
func NewMetadata(ctx context.Context, rawURL string, options Options) (result Metadata, err error) {
if options.noInfoDownload {
return Metadata{
RawURL: rawURL,
Options: options,
}, nil
}
info, rawJSON, err := infoFromURL(ctx, rawURL, options)
if err != nil {
return Metadata{}, err
}
rawJSONCopy := make([]byte, len(rawJSON))
copy(rawJSONCopy, rawJSON)
return Metadata{
Info: info,
RawURL: rawURL,
RawJSON: rawJSONCopy,
Options: options,
}, nil
}

View File

@@ -1,4 +1,4 @@
package ytdl
package dl
import (
"bufio"
@@ -15,7 +15,7 @@ import (
"strings"
)
// Options for New()
// Options for NewMetadata()
type Options struct {
Type Type
PlaylistStart uint // --playlist-start
@@ -38,7 +38,7 @@ type Options struct {
Fixup string // --fixup
SortingFormat string // --format-sort
// Set to true if you don't want to use the result.Info structure after the goutubedl.New() call,
// Set to true if you don't want to use the result.Info structure after the goutubedl.NewMetadata() call,
// so the given URL will be downloaded in a single pass in the DownloadResult.Download() call.
noInfoDownload bool
}
@@ -52,9 +52,10 @@ type DownloadOptions struct {
ForceOverwrites bool // --force-overwrites replaces existing files
DisableCaching bool // --no-cache-dir
PlaylistIndex int // --playlist-items index of the file to download if there is more than one video
Output string
}
func (result Result) DownloadWithOptions(
func (result Metadata) DownloadWithOptions(
ctx context.Context,
options DownloadOptions,
) (*DownloadResult, error) {
@@ -89,7 +90,7 @@ func (result Result) DownloadWithOptions(
cmd := exec.CommandContext(
ctx,
FindBin(),
FindYtDlpBin(),
// see comment below about ignoring errors for playlists
"--ignore-errors",
// TODO: deprecated in yt-dlp?
@@ -101,10 +102,14 @@ func (result Result) DownloadWithOptions(
"--restrict-filenames",
// use .netrc authentication data
"--netrc",
// write to stdout
"--output", "-",
)
if options.Output != "" {
cmd.Args = append(cmd.Args, "--output", options.Output)
} else {
cmd.Args = append(cmd.Args, "--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", "-")
@@ -131,6 +136,7 @@ func (result Result) DownloadWithOptions(
} else {
cmd.Args = append(cmd.Args, "--load-info", jsonTempPath)
}
// force IPV4 Usage
if result.Options.UseIPV4 {
cmd.Args = append(cmd.Args, "-4")

View File

@@ -1,18 +1,10 @@
package ytdl
package dl
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
@@ -22,7 +14,7 @@ type DownloadResult 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) {
func (result Metadata) Download(ctx context.Context, filter string) (*DownloadResult, error) {
return result.DownloadWithOptions(ctx, DownloadOptions{
Filter: filter,
})
@@ -41,7 +33,7 @@ func (dr *DownloadResult) Close() error {
// Formats return all formats
// helper to take care of mixed info and format
func (result Result) Formats() []Format {
func (result Metadata) Formats() []Format {
if len(result.Info.Formats) > 0 {
return result.Info.Formats
}

View File

@@ -1,4 +1,4 @@
package ytdl
package dl
// Subtitle youtube-dl subtitle
type Subtitle struct {

View File

@@ -1,4 +1,4 @@
package ytdl
package dl
type Thumbnail struct {
ID string `json:"id"`

View File

@@ -1,4 +1,4 @@
package ytdl
package dl
// Type of response you want
type Type int

View File

@@ -1,4 +1,4 @@
package ytdl
package dl
import (
"context"
@@ -9,7 +9,7 @@ import (
// 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")
cmd := exec.CommandContext(ctx, FindYtDlpBin(), "--version")
versionBytes, cmdErr := cmd.Output()
if cmdErr != nil {
return "", cmdErr

View File

@@ -1,4 +1,4 @@
package ytdl
package dl
import (
"context"
@@ -21,8 +21,8 @@ func TestVersion(t *testing.T) {
}
})
t.Run("InvalidBin", func(t *testing.T) {
defer func(orig string) { Bin = orig }(Bin)
Bin = "/non-existing"
defer func(orig string) { YtDlpBin = orig }(YtDlpBin)
YtDlpBin = "/non-existing"
_, versionErr := Version(context.Background())
if versionErr == nil || !strings.Contains(versionErr.Error(), "no such file or directory") {

View File

@@ -532,6 +532,10 @@ func (m *MediaFile) MimeType() string {
m.mimeType = fs.MimeType(fileName)
if m.mimeType == header.ContentTypeMp4 && m.MetaData().Codec == video.CodecM2TS {
m.mimeType = header.ContentTypeM2TS
}
return m.mimeType
}
@@ -814,6 +818,17 @@ func (m *MediaFile) IsAvifS() bool {
return m.HasFileType(fs.ImageAvifS)
}
// IsM2TS checks if the file is an MPEG-2 Transport Stream (M2TS) container.
func (m *MediaFile) IsM2TS() bool {
if t := fs.FileType(m.fileName); t == fs.VideoM2TS {
return true
} else if t == fs.VideoMp4 || t == fs.VideoAvcHD {
return m.HasMimeType(header.ContentTypeM2TS)
}
return false
}
// IsBmp checks if the file is a bitmap image with a supported file type extension.
func (m *MediaFile) IsBmp() bool {
if fs.FileType(m.fileName) != fs.ImageBmp {

View File

@@ -12,6 +12,7 @@ import (
"github.com/photoprism/photoprism/internal/meta"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/media"
"github.com/photoprism/photoprism/pkg/media/http/header"
"github.com/photoprism/photoprism/pkg/media/projection"
"github.com/photoprism/photoprism/pkg/media/video"
"github.com/photoprism/photoprism/pkg/time/tz"
@@ -73,10 +74,11 @@ func TestMediaFile_SidecarJsonName(t *testing.T) {
}
func TestMediaFile_NeedsExifToolJson(t *testing.T) {
c := config.TestConfig()
t.Run("false", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/beach_sand.jpg")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/beach_sand.jpg")
if err != nil {
t.Fatal(err)
@@ -85,9 +87,7 @@ func TestMediaFile_NeedsExifToolJson(t *testing.T) {
assert.True(t, mediaFile.NeedsExifToolJson())
})
t.Run("true", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/blue-go-video.mp4")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/blue-go-video.mp4")
if err != nil {
t.Fatal(err)
@@ -96,9 +96,7 @@ func TestMediaFile_NeedsExifToolJson(t *testing.T) {
assert.True(t, mediaFile.NeedsExifToolJson())
})
t.Run("true", func(t *testing.T) {
conf := config.TestConfig()
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/blue-go-video.mp4.json")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/blue-go-video.mp4.json")
if err != nil {
t.Fatal(err)
@@ -109,10 +107,108 @@ func TestMediaFile_NeedsExifToolJson(t *testing.T) {
}
func TestMediaFile_CreateExifToolJson(t *testing.T) {
conf := config.TestConfig()
c := config.TestConfig()
t.Run("bear.m2ts", func(t *testing.T) {
mediaFile, err := NewMediaFile(filepath.Join(c.ExamplesPath(), "bear.m2ts"))
if err != nil {
t.Fatal(err)
}
assert.Equal(t, fs.VideoM2TS, mediaFile.FileType())
jsonName, err := mediaFile.ExifToolJsonName()
if fs.FileExists(jsonName) {
if err = os.Remove(jsonName); err != nil {
t.Error(err)
}
}
assert.True(t, mediaFile.NeedsExifToolJson())
err = mediaFile.CreateExifToolJson(NewConvert(c))
if err != nil {
t.Fatal(err)
}
data := mediaFile.MetaData()
assert.Empty(t, err)
assert.IsType(t, meta.Data{}, data)
assert.Equal(t, "0001-01-01 00:00:00 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "0001-01-01 00:00:00 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, video.CodecM2TS, data.Codec)
assert.Equal(t, 320, data.Width)
assert.Equal(t, 192, data.Height)
assert.Equal(t, false, data.Flash)
assert.Equal(t, "", data.Caption)
assert.True(t, mediaFile.IsM2TS())
assert.True(t, mediaFile.IsVideo())
assert.True(t, mediaFile.HasMediaType(media.Video))
assert.Equal(t, fs.VideoM2TS, mediaFile.FileType())
assert.Equal(t, header.ContentTypeM2TS, mediaFile.MimeType())
assert.Equal(t, header.ContentTypeM2TS, mediaFile.ContentType())
if err = os.Remove(jsonName); err != nil {
t.Error(err)
}
})
t.Run("m2ts.mp4", func(t *testing.T) {
mediaFile, err := NewMediaFile(filepath.Join(c.ExamplesPath(), "m2ts.mp4"))
if err != nil {
t.Fatal(err)
}
assert.Equal(t, fs.VideoMp4, mediaFile.FileType())
jsonName, err := mediaFile.ExifToolJsonName()
if fs.FileExists(jsonName) {
if err = os.Remove(jsonName); err != nil {
t.Error(err)
}
}
assert.True(t, mediaFile.NeedsExifToolJson())
err = mediaFile.CreateExifToolJson(NewConvert(c))
if err != nil {
t.Fatal(err)
}
data := mediaFile.MetaData()
assert.Empty(t, err)
assert.IsType(t, meta.Data{}, data)
assert.Equal(t, "0001-01-01 00:00:00 +0000 UTC", data.TakenAt.String())
assert.Equal(t, "0001-01-01 00:00:00 +0000 UTC", data.TakenAtLocal.String())
assert.Equal(t, video.CodecM2TS, data.Codec)
assert.Equal(t, 320, data.Width)
assert.Equal(t, 192, data.Height)
assert.Equal(t, false, data.Flash)
assert.Equal(t, "", data.Caption)
assert.True(t, mediaFile.IsM2TS())
assert.True(t, mediaFile.IsVideo())
assert.True(t, mediaFile.HasMediaType(media.Video))
assert.Equal(t, fs.VideoMp4, mediaFile.FileType())
assert.Equal(t, header.ContentTypeM2TS, mediaFile.MimeType())
assert.Equal(t, header.ContentTypeM2TS, mediaFile.ContentType())
if err = os.Remove(jsonName); err != nil {
t.Error(err)
}
})
t.Run("gopher-video.mp4", func(t *testing.T) {
mediaFile, err := NewMediaFile(conf.ExamplesPath() + "/gopher-video.mp4")
mediaFile, err := NewMediaFile(c.ExamplesPath() + "/gopher-video.mp4")
if err != nil {
t.Fatal(err)
@@ -128,7 +224,7 @@ func TestMediaFile_CreateExifToolJson(t *testing.T) {
assert.True(t, mediaFile.NeedsExifToolJson())
err = mediaFile.CreateExifToolJson(NewConvert(conf))
err = mediaFile.CreateExifToolJson(NewConvert(c))
if err != nil {
t.Fatal(err)
@@ -156,10 +252,10 @@ func TestMediaFile_CreateExifToolJson(t *testing.T) {
}
func TestMediaFile_Exif_Jpeg(t *testing.T) {
conf := config.TestConfig()
c := config.TestConfig()
t.Run("elephants.jpg", func(t *testing.T) {
img, err := NewMediaFile(conf.ExamplesPath() + "/elephants.jpg")
img, err := NewMediaFile(c.ExamplesPath() + "/elephants.jpg")
if err != nil {
t.Fatal(err)
@@ -198,7 +294,7 @@ func TestMediaFile_Exif_Jpeg(t *testing.T) {
})
t.Run("fern_green.jpg", func(t *testing.T) {
img, err := NewMediaFile(conf.ExamplesPath() + "/fern_green.jpg")
img, err := NewMediaFile(c.ExamplesPath() + "/fern_green.jpg")
if err != nil {
t.Fatal(err)
@@ -232,9 +328,8 @@ func TestMediaFile_Exif_Jpeg(t *testing.T) {
t.Logf("UTC: %s", info.TakenAt.String())
t.Logf("Local: %s", info.TakenAtLocal.String())
})
t.Run("blue-go-video.mp4", func(t *testing.T) {
img, err := NewMediaFile(conf.ExamplesPath() + "/blue-go-video.mp4")
img, err := NewMediaFile(c.ExamplesPath() + "/blue-go-video.mp4")
if err != nil {
t.Fatal(err)
@@ -246,7 +341,6 @@ func TestMediaFile_Exif_Jpeg(t *testing.T) {
assert.IsType(t, meta.Data{}, info)
})
t.Run("panorama360.jpg", func(t *testing.T) {
img, err := NewMediaFile("testdata/panorama360.jpg")
@@ -281,7 +375,6 @@ func TestMediaFile_Exif_Jpeg(t *testing.T) {
assert.Equal(t, 1, data.Orientation)
assert.Equal(t, projection.Equirectangular.String(), data.Projection)
})
t.Run("digikam.jpg", func(t *testing.T) {
img, err := NewMediaFile("testdata/digikam.jpg")

View File

@@ -765,6 +765,26 @@ func TestMediaFile_MimeType(t *testing.T) {
assert.Equal(t, "video/mp4", f.MimeType())
}
})
t.Run("bear.m2ts", func(t *testing.T) {
if f, err := NewMediaFile(filepath.Join(c.ExamplesPath(), "bear.m2ts")); err != nil {
t.Fatal(err)
} else {
assert.True(t, f.IsM2TS())
assert.Equal(t, fs.VideoM2TS, f.FileType())
assert.Equal(t, header.ContentTypeM2TS, f.MimeType())
assert.Equal(t, header.ContentTypeM2TS, f.ContentType())
}
})
t.Run("m2ts.mp4", func(t *testing.T) {
if f, err := NewMediaFile(filepath.Join(c.ExamplesPath(), "m2ts.mp4")); err != nil {
t.Fatal(err)
} else {
assert.False(t, f.IsM2TS())
assert.Equal(t, fs.VideoMp4, f.FileType())
assert.Equal(t, header.ContentTypeMp4, f.MimeType())
assert.Equal(t, header.ContentTypeMp4, f.ContentType())
}
})
t.Run("earth.avi", func(t *testing.T) {
if f, err := NewMediaFile(filepath.Join(c.ExamplesPath(), "earth.avi")); err != nil {
t.Fatal(err)

View File

@@ -1,30 +0,0 @@
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

@@ -155,8 +155,11 @@ var Extensions = FileExtensions{
".flv": VideoFlash,
".f4v": VideoFlash,
".mkv": VideoMkv,
".ts": VideoM2TS,
".m2t": VideoM2TS,
".m2ts": VideoM2TS,
".mp2t": VideoM2TS,
".mts": VideoAvcHD,
".m2ts": VideoBDAV,
".ogv": VideoTheora,
".ogg": VideoTheora,
".ogx": VideoTheora,

View File

@@ -51,8 +51,8 @@ var TypeInfo = TypeMap{
VideoMkv: "Matroska Multimedia Container",
VideoMpeg: "Moving Picture Experts Group (MPEG)",
VideoMjpeg: "Motion JPEG",
VideoM2TS: "MPEG-2 Transport Stream (M2TS)",
VideoAvcHD: "Advanced Video Coding High Definition (AVCHD)",
VideoBDAV: "Blu-ray MPEG-2 Transport Stream",
VideoTheora: "Ogg Media (OGG)",
SidecarXMP: "Adobe Extensible Metadata Platform",
SidecarAppleXml: "Apple Image Edits XML",

View File

@@ -87,8 +87,8 @@ const (
Video3GP Type = "3gp" // Mobile Multimedia Container, MPEG-4 Part 12
Video3G2 Type = "3g2" // Similar to 3GP, consumes less space & bandwidth
VideoFlash Type = "flv" // Flash Video
VideoM2TS Type = "m2t" // MPEG-2 Transport Stream (M2TS)
VideoAvcHD Type = "mts" // AVCHD (Advanced Video Coding High Definition)
VideoBDAV Type = "m2ts" // Blu-ray MPEG-2 Transport Stream
VideoTheora Type = "ogv" // Ogg container format maintained by the Xiph.Org, free and open
VideoASF Type = "asf" // Advanced Systems/Streaming Format (ASF)
VideoAVI Type = "avi" // Microsoft Audio Video Interleave (AVI)

View File

@@ -29,6 +29,9 @@ func MimeType(filename string) (mimeType string) {
// Determine mime type based on the extension for the following
// formats, which otherwise cannot be reliably distinguished:
switch fileType {
// MPEG-2 Transport Stream
case VideoM2TS, VideoAvcHD:
return header.ContentTypeM2TS
// Apple QuickTime Container
case VideoMov:
return header.ContentTypeMov

View File

@@ -56,8 +56,8 @@ var Formats = map[fs.Type]Type{
fs.Video3GP: Video,
fs.Video3G2: Video,
fs.VideoFlash: Video,
fs.VideoM2TS: Video,
fs.VideoAvcHD: Video,
fs.VideoBDAV: Video,
fs.VideoTheora: Video,
fs.VideoASF: Video,
fs.VideoWMV: Video,

View File

@@ -23,6 +23,7 @@ import (
// Standard ContentType strings for audio and video files:
const (
ContentTypeM2TS = "video/mp2t"
ContentTypeM4v = "video/x-m4v"
ContentTypeMp4 = "video/mp4"
ContentTypeMp4Avc = ContentTypeMp4 + "; codecs=\"avc1\"" // MPEG-4 AVC (H.264)

View File

@@ -76,7 +76,6 @@ var CompatibleBrands = Chunks{
ChunkHEV2,
ChunkHEV3,
ChunkDVHE,
ChunkHEIC,
ChunkAV01,
ChunkAV1C,
ChunkMMP4,

View File

@@ -60,13 +60,13 @@ func (c Chunk) FileOffset(fileName string) (int, error) {
defer file.Close()
index, err := c.DataOffset(file, -1)
index, err := c.DataOffset(file, 0, -1)
return index, err
}
// DataOffset returns the index of the chunk in file, or -1 if it was not found.
func (c Chunk) DataOffset(file io.ReadSeeker, maxOffset int) (int, error) {
func (c Chunk) DataOffset(file io.ReadSeeker, offset, maxOffset int) (int, error) {
if file == nil {
return -1, errors.New("file is nil")
}
@@ -79,8 +79,11 @@ func (c Chunk) DataOffset(file io.ReadSeeker, maxOffset int) (int, error) {
// Create buffered read seeker.
r := bufseekio.NewReadSeeker(file, blockSize, dataSize)
// Index offset.
var offset int
if seekOffset, seekErr := r.Seek(int64(offset), io.SeekStart); seekErr != nil {
return -1, seekErr
} else {
offset = int(seekOffset)
}
// Search in batches.
for {

View File

@@ -39,6 +39,17 @@ func TestChunk_FileOffset(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, 23213, index)
})
t.Run("motion-photo.heif", func(t *testing.T) {
index, err := ChunkFTYP.FileOffset("testdata/motion-photo.heif")
require.NoError(t, err)
assert.Equal(t, 4, index)
index, err = ChunkHEIC.FileOffset("testdata/motion-photo.heif")
require.NoError(t, err)
assert.Equal(t, 8, index)
index, err = ChunkHVC1.FileOffset("testdata/motion-photo.heif")
require.NoError(t, err)
assert.Equal(t, 976016, index)
})
}
func TestChunks(t *testing.T) {

View File

@@ -33,6 +33,7 @@ const (
CodecVp08 Codec = "vp08" // Google VP8
CodecVp09 Codec = "vp09" // Google VP9
CodecTheora Codec = "ogv" // Ogg Vorbis Video
CodecM2TS Codec = "m2t" // MPEG-2 Transport Stream
CodecWebm Codec = "webm" // Google WebM
)

View File

@@ -64,6 +64,8 @@ func ContentType(mediaType, fileType, videoCodec string, hdr bool) string {
mediaType = header.ContentTypeMp4
case fs.VideoMkv.Equal(fileType):
mediaType = header.ContentTypeMkv
case fs.VideoM2TS.Equal(fileType) || videoCodec == CodecM2TS:
mediaType = header.ContentTypeM2TS
}
}

View File

@@ -153,8 +153,7 @@ func Probe(file io.ReadSeeker) (info Info, err error) {
// If no AVC video was found, search the video data for High Efficiency Video Coding (HEVC) chunks,
// see https://stackoverflow.com/questions/63468587/what-hevc-codec-tag-to-use-with-fmp4-hvc1-or-hev1.
if info.VideoCodec == "" {
// To improve performance, only search for "hvc1" as that is the most common HEVC video identifier.
if fileOffset, fileErr := ChunkHVC1.DataOffset(file, -1); fileOffset > 0 && fileErr == nil {
if fileOffset, fileErr := ChunkHVC1.DataOffset(file, 0, -1); fileOffset > 0 && fileErr == nil {
info.VideoCodec = CodecHvc1
}
}

View File

@@ -252,4 +252,34 @@ func TestProbe(t *testing.T) {
assert.Equal(t, false, info.FastStart)
assert.Equal(t, true, info.Compatible)
})
t.Run("motion-photo.heif", func(t *testing.T) {
f, fileErr := os.Open("testdata/motion-photo.heif")
require.NoError(t, fileErr)
defer f.Close()
info, err := Probe(f)
require.NoError(t, err)
require.NotNil(t, info)
assert.Equal(t, "", info.FileName)
assert.Equal(t, int64(-1), info.FileSize)
assert.Equal(t, fs.TypeUnknown, info.FileType)
assert.Equal(t, Mp4, info.VideoType)
assert.Equal(t, int64(978741), info.VideoOffset)
assert.Equal(t, int64(0), info.ThumbOffset)
assert.Equal(t, media.Live, info.MediaType)
assert.Equal(t, CodecHvc1, info.VideoCodec)
assert.Equal(t, header.ContentTypeMp4, info.VideoMimeType)
assert.Equal(t, header.ContentTypeMp4HvcMain10, info.VideoContentType())
assert.Equal(t, "2.9686s", info.Duration.String())
assert.InEpsilon(t, 2.9686, info.Duration.Seconds(), 0.01)
assert.Equal(t, 2, info.Tracks)
assert.Equal(t, 0, info.VideoWidth)
assert.Equal(t, 0, info.VideoHeight)
assert.Equal(t, 89, info.Frames)
assert.Equal(t, 30.0, info.FPS)
assert.Equal(t, false, info.Encrypted)
assert.Equal(t, false, info.FastStart)
assert.Equal(t, true, info.Compatible)
})
}

View File

@@ -74,6 +74,9 @@ var Types = Standards{
"mkv1": MkvAv1,
"ogg": Theora, // ↓ Theora video in OGG container
"ogv": Theora,
"m2t": M2TS, // ↓ MPEG-2 Transport Stream container
"m2ts": M2TS,
"mp2t": M2TS,
"mp4": Mp4, // ↓ Unknown codec in MP4 container
"mpeg4": Mp4,
"webm": Webm, // ↓ Unknown codec in WebM container

BIN
pkg/media/video/testdata/bear.m2ts vendored Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -21,6 +21,16 @@ var Mp4 = Type{
Public: true,
}
// M2TS specifies the MPEG-2 Transport Stream (M2TS) multimedia container format.
var M2TS = Type{
Codec: CodecAvc1,
FileType: fs.VideoM2TS,
ContentType: header.ContentTypeM2TS,
WidthLimit: 8192,
HeightLimit: 4320,
Public: false,
}
// Mov specifies the Apple QuickTime (QT) container format.
var Mov = Type{
Codec: CodecAvc1,