mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-14 12:23:43 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe25ccab1d | ||
|
|
8301b1f9ce | ||
|
|
49aab4ea7c | ||
|
|
5d8267fa56 | ||
|
|
1ab452ced8 | ||
|
|
743f356567 | ||
|
|
b1dd46cc7c | ||
|
|
38750d8505 | ||
|
|
7c1447f659 | ||
|
|
21702d5d46 | ||
|
|
e99ef6c7f4 | ||
|
|
c667f097dc | ||
|
|
46c34f2e92 | ||
|
|
e47a20a1bd | ||
|
|
cee5f3336e | ||
|
|
a760290213 | ||
|
|
a29f4bb42a | ||
|
|
62e8ab3cef | ||
|
|
8fa9a1e902 | ||
|
|
46675f26af | ||
|
|
0d52b840a2 | ||
|
|
091e7b8936 | ||
|
|
ff8f61423e |
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
# Keep upstream plugin sources recognizable and collapsed in GitHub reviews.
|
||||||
|
ui/flutter/third_party/** linguist-vendored whitespace=-blank-at-eol,-blank-at-eof
|
||||||
@@ -77,6 +77,8 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- name: Windows
|
- name: Windows
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
|
- name: Linux
|
||||||
|
os: ubuntu-latest
|
||||||
- name: macOS
|
- name: macOS
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
@@ -89,9 +91,25 @@ jobs:
|
|||||||
if: runner.os == 'macOS'
|
if: runner.os == 'macOS'
|
||||||
with:
|
with:
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
- name: Install Linux WebView dependencies
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update -y
|
||||||
|
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev xvfb
|
||||||
|
- name: Test webview
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
# Match the full suite's coverage instrumentation and repeat on the same
|
||||||
|
# GTK event loop to catch queued callbacks after engine destruction.
|
||||||
|
run: |
|
||||||
|
PACKAGES=$(go list ./... | awk '!/\/bind\// && (!/\/cmd\// || /\/cmd\/web$/)')
|
||||||
|
go test -c -tags webview -coverpkg=$(echo "$PACKAGES" | paste -sd "," -) -covermode=count -o "$RUNNER_TEMP/goprovider.test" ./internal/webview/goprovider
|
||||||
|
xvfb-run -a "$RUNNER_TEMP/goprovider.test" -test.v -test.count=5 -test.timeout=300s -test.coverprofile="$RUNNER_TEMP/webview-coverage.out"
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: 1
|
||||||
- name: Test webview
|
- name: Test webview
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
run: go test -tags webview -v -count=1 -timeout 300s ./internal/webview/goprovider/
|
# Repeat close/reopen coverage to catch browser shutdown races.
|
||||||
|
run: go test -tags webview -v -count=3 -timeout 300s ./internal/webview/goprovider/
|
||||||
env:
|
env:
|
||||||
CGO_ENABLED: 1
|
CGO_ENABLED: 1
|
||||||
- name: Test webview
|
- name: Test webview
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 391 KiB After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 357 KiB After Width: | Height: | Size: 1.3 MiB |
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
nativebridge "github.com/GopeedLab/gopeed/bind/native"
|
nativebridge "github.com/GopeedLab/gopeed/bind/native"
|
||||||
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
|
"github.com/GopeedLab/gopeed/internal/webview/rpcprovider"
|
||||||
|
goapi "github.com/GopeedLab/gopeed/pkg/api"
|
||||||
"github.com/GopeedLab/gopeed/pkg/rest"
|
"github.com/GopeedLab/gopeed/pkg/rest"
|
||||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||||
)
|
)
|
||||||
@@ -90,3 +91,14 @@ func applyWebViewProvider(config *model.StartConfig) {
|
|||||||
}
|
}
|
||||||
config.WebViewProvider = rpcprovider.New(*config.WebViewRPCConfig)
|
config.WebViewProvider = rpcprovider.New(*config.WebViewRPCConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LiveActivityTaskEventMask() int64 {
|
||||||
|
return int64(
|
||||||
|
goapi.TaskEventDone |
|
||||||
|
goapi.TaskEventError |
|
||||||
|
goapi.TaskEventStart |
|
||||||
|
goapi.TaskEventProgress |
|
||||||
|
goapi.TaskEventPause |
|
||||||
|
goapi.TaskEventDelete,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -37,15 +37,17 @@ func installByInstaller(killSignalChan chan<- any, packagePath, destDir string)
|
|||||||
}
|
}
|
||||||
defer reader.Close()
|
defer reader.Close()
|
||||||
|
|
||||||
// Find the installer file
|
|
||||||
var installerPath string
|
var installerPath string
|
||||||
for _, file := range reader.File {
|
for _, file := range reader.File {
|
||||||
|
cleanName := filepath.Clean(file.Name)
|
||||||
|
if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if file.FileInfo().IsDir() {
|
if file.FileInfo().IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract file
|
path := filepath.Join(tempDir, cleanName)
|
||||||
path := filepath.Join(tempDir, file.Name)
|
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -101,7 +103,11 @@ func installByPortable(killSignalChan chan<- any, packagePath, destDir string) e
|
|||||||
defer reader.Close()
|
defer reader.Close()
|
||||||
|
|
||||||
for _, file := range reader.File {
|
for _, file := range reader.File {
|
||||||
path := filepath.Join(destDir, file.Name)
|
cleanName := filepath.Clean(file.Name)
|
||||||
|
if strings.HasPrefix(cleanName, "..") || filepath.IsAbs(cleanName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(destDir, cleanName)
|
||||||
|
|
||||||
if file.FileInfo().IsDir() {
|
if file.FileInfo().IsDir() {
|
||||||
os.MkdirAll(path, file.Mode())
|
os.MkdirAll(path, file.Mode())
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type args struct {
|
type args struct {
|
||||||
|
TempDir *string `json:"tempDir"`
|
||||||
Address *string `json:"address"`
|
Address *string `json:"address"`
|
||||||
Port *int `json:"port"`
|
Port *int `json:"port"`
|
||||||
Username *string `json:"username"`
|
Username *string `json:"username"`
|
||||||
@@ -54,6 +55,7 @@ func loadCliArgs() *args {
|
|||||||
cfg.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
|
cfg.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
|
||||||
flag.StringVar(cfg.ApiToken, "api-token", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
|
flag.StringVar(cfg.ApiToken, "api-token", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
|
||||||
flag.BoolVar(&cfg.MCPEnable, "mcp-enable", false, "Enable MCP endpoint")
|
flag.BoolVar(&cfg.MCPEnable, "mcp-enable", false, "Enable MCP endpoint")
|
||||||
|
cfg.TempDir = flag.String("temp-dir", "", "Temporary directory (defaults to the OS temporary directory)")
|
||||||
cfg.StorageDir = flag.String("d", "", "Storage directory")
|
cfg.StorageDir = flag.String("d", "", "Storage directory")
|
||||||
flag.StringVar(cfg.StorageDir, "storage-dir", "", "Storage directory")
|
flag.StringVar(cfg.StorageDir, "storage-dir", "", "Storage directory")
|
||||||
whiteDownloadDirs := flag.String("white-download-dirs", "", "White download directories, comma-separated")
|
whiteDownloadDirs := flag.String("white-download-dirs", "", "White download directories, comma-separated")
|
||||||
@@ -89,6 +91,8 @@ func overrideWithCliArgs(cfg *args, cliConfig *args) {
|
|||||||
cfg.ApiToken = cliConfig.ApiToken
|
cfg.ApiToken = cliConfig.ApiToken
|
||||||
case "mcp-enable":
|
case "mcp-enable":
|
||||||
cfg.MCPEnable = cliConfig.MCPEnable
|
cfg.MCPEnable = cliConfig.MCPEnable
|
||||||
|
case "temp-dir":
|
||||||
|
cfg.TempDir = cliConfig.TempDir
|
||||||
case "d", "storage-dir":
|
case "d", "storage-dir":
|
||||||
cfg.StorageDir = cliConfig.StorageDir
|
cfg.StorageDir = cliConfig.StorageDir
|
||||||
case "white-download-dirs":
|
case "white-download-dirs":
|
||||||
@@ -101,6 +105,9 @@ func overrideWithCliArgs(cfg *args, cliConfig *args) {
|
|||||||
|
|
||||||
// setDefaults sets default values for any unset configuration fields
|
// setDefaults sets default values for any unset configuration fields
|
||||||
func setDefaults(cfg *args, cliConfig *args) {
|
func setDefaults(cfg *args, cliConfig *args) {
|
||||||
|
if cfg.TempDir == nil {
|
||||||
|
cfg.TempDir = cliConfig.TempDir
|
||||||
|
}
|
||||||
if cfg.Address == nil {
|
if cfg.Address == nil {
|
||||||
cfg.Address = cliConfig.Address
|
cfg.Address = cliConfig.Address
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,11 +43,16 @@ func main() {
|
|||||||
storageDir = filepath.Join(filepath.Dir(exe), "storage")
|
storageDir = filepath.Join(filepath.Dir(exe), "storage")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tempDir := ""
|
||||||
|
if args.TempDir != nil {
|
||||||
|
tempDir = *args.TempDir
|
||||||
|
}
|
||||||
cfg := &model.StartConfig{
|
cfg := &model.StartConfig{
|
||||||
Network: "tcp",
|
Network: "tcp",
|
||||||
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
|
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
|
||||||
Storage: model.StorageBolt,
|
Storage: model.StorageBolt,
|
||||||
StorageDir: storageDir,
|
StorageDir: storageDir,
|
||||||
|
TempDir: tempDir,
|
||||||
WhiteDownloadDirs: args.WhiteDownloadDirs,
|
WhiteDownloadDirs: args.WhiteDownloadDirs,
|
||||||
ApiToken: *args.ApiToken,
|
ApiToken: *args.ApiToken,
|
||||||
MCPEnable: args.MCPEnable,
|
MCPEnable: args.MCPEnable,
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ module github.com/GopeedLab/gopeed
|
|||||||
go 1.25.4
|
go 1.25.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5
|
codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0
|
||||||
|
github.com/GopeedLab/webview_go v0.0.0-20260918010912-0d4c27bb7914
|
||||||
github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0
|
github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||||
github.com/bodgit/sevenzip v1.6.1
|
github.com/bodgit/sevenzip v1.6.1
|
||||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||||
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
|
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
|
||||||
github.com/go-git/go-git/v5 v5.8.1
|
github.com/go-git/go-git/v5 v5.8.1
|
||||||
|
github.com/google/jsonschema-go v0.4.3
|
||||||
github.com/gorilla/handlers v1.5.1
|
github.com/gorilla/handlers v1.5.1
|
||||||
github.com/gorilla/mux v1.8.0
|
github.com/gorilla/mux v1.8.0
|
||||||
github.com/imroc/req/v3 v3.52.2
|
github.com/imroc/req/v3 v3.52.2
|
||||||
@@ -21,6 +23,7 @@ require (
|
|||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529
|
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529
|
||||||
github.com/rs/zerolog v1.31.0
|
github.com/rs/zerolog v1.31.0
|
||||||
|
github.com/tetratelabs/wazero v1.12.0
|
||||||
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad
|
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad
|
||||||
go.etcd.io/bbolt v1.4.3
|
go.etcd.io/bbolt v1.4.3
|
||||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
|
||||||
@@ -38,7 +41,6 @@ require (
|
|||||||
github.com/felixge/fgprof v0.9.5 // indirect
|
github.com/felixge/fgprof v0.9.5 // indirect
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
@@ -156,7 +158,7 @@ require (
|
|||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/mod v0.33.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/net v0.50.0
|
golang.org/x/net v0.50.0
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.34.0
|
golang.org/x/text v0.34.0
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
golang.org/x/tools v0.42.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2k
|
|||||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||||
|
codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0 h1:AqNFl60o4xhTTOxmzAvSf7pazXHKBGuV5ATw7sETIRA=
|
||||||
|
codeberg.org/gruf/go-ffmpreg v0.6.23-0.20260827095416-df1836cb5be0/go.mod h1:XM6eW8CSgAuZRKRynm5nDiSNedm0rav30IychqygOHE=
|
||||||
crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk=
|
crawshaw.io/iox v0.0.0-20181124134642-c51c3df30797/go.mod h1:sXBiorCo8c46JlQV3oXPKINnZ8mcqnye1EkVkqsectk=
|
||||||
crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4=
|
crawshaw.io/sqlite v0.3.2/go.mod h1:igAO5JulrQ1DbdZdtVq48mnZUBAPOeFzer7VhDWNtW4=
|
||||||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||||
@@ -23,8 +25,20 @@ filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmG
|
|||||||
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
|
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5 h1:86LVTaNDM/j/uTAD2HedkXNO9LIQlt642VAdbU/0D/M=
|
github.com/GopeedLab/webview_go v0.0.0-20260915010614-2567e492369a h1:Vzz+8aMVX9ZPPpkxgxa7mCorrAgdXCZL5EunOpOBvao=
|
||||||
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
|
github.com/GopeedLab/webview_go v0.0.0-20260915010614-2567e492369a/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
|
||||||
|
github.com/GopeedLab/webview_go v0.0.0-20260917153919-dcf00737f32a h1:nz1bC6TGwQ1kv/OVLahNZ0EEtb7v5LHfceAkW4MvS6I=
|
||||||
|
github.com/GopeedLab/webview_go v0.0.0-20260917153919-dcf00737f32a/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
|
||||||
|
github.com/GopeedLab/webview_go v0.0.0-20260917155039-1ee7ffb4169a h1:yudcfXpipKLnMaaw1losn6YeOBjvj2rAFicfjqYJexY=
|
||||||
|
github.com/GopeedLab/webview_go v0.0.0-20260917155039-1ee7ffb4169a/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
|
||||||
|
github.com/GopeedLab/webview_go v0.0.0-20260917155218-bd16da0d6f53 h1:nMSHKhbrEaWPE/x3mYl/270GPjswbUmS+E7yYjly4xk=
|
||||||
|
github.com/GopeedLab/webview_go v0.0.0-20260917155218-bd16da0d6f53/go.mod h1:oNO07SCX8xgoHLds2OBwML8TU4xils88vMQ4gadKUHQ=
|
||||||
|
github.com/GopeedLab/webview_go v0.0.0-20260917161756-2a4f989dcb1d h1:4fjlhJpBWa6IcdKVPkEMMIDd6Xo9JM09dJHdKU+1V7k=
|
||||||
|
github.com/GopeedLab/webview_go v0.0.0-20260917161756-2a4f989dcb1d/go.mod h1:oNO07SCX8xgoHLds2OBwML8TU4xils88vMQ4gadKUHQ=
|
||||||
|
github.com/GopeedLab/webview_go v0.0.0-20260917163929-ca26c3e6bcfa h1:VkdBMcNHYTqCMwOOCimA7b3xZKV/qFIGrhh3+vm9n3s=
|
||||||
|
github.com/GopeedLab/webview_go v0.0.0-20260917163929-ca26c3e6bcfa/go.mod h1:oNO07SCX8xgoHLds2OBwML8TU4xils88vMQ4gadKUHQ=
|
||||||
|
github.com/GopeedLab/webview_go v0.0.0-20260918010912-0d4c27bb7914 h1:9VJQZIZ1R6T4m+4vlNQYNXSPNUv2gf2PiTfIXiQ3MTw=
|
||||||
|
github.com/GopeedLab/webview_go v0.0.0-20260918010912-0d4c27bb7914/go.mod h1:oNO07SCX8xgoHLds2OBwML8TU4xils88vMQ4gadKUHQ=
|
||||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||||
@@ -552,6 +566,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
|||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||||
|
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||||
github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
|
github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
|
||||||
github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
|
github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
|
||||||
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||||
@@ -732,6 +748,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/GopeedLab/gopeed/internal/production"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -61,6 +62,7 @@ type Metadata struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Source struct {
|
type Source struct {
|
||||||
|
producer production.Reader
|
||||||
ID string
|
ID string
|
||||||
URL string
|
URL string
|
||||||
ContentType string
|
ContentType string
|
||||||
@@ -359,11 +361,35 @@ func (r *Registry) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer reader.Close()
|
defer reader.Close()
|
||||||
|
if producer, ok := reader.(production.Reader); ok {
|
||||||
|
src.mu.Lock()
|
||||||
|
src.producer = producer
|
||||||
|
src.mu.Unlock()
|
||||||
|
if err := producer.WaitProduction(req.Context()); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if producer.Production() != nil {
|
||||||
|
meta.Size = 0
|
||||||
|
meta.Range = false
|
||||||
|
start = 0
|
||||||
|
end = -1
|
||||||
|
ranged = false
|
||||||
|
src.mu.Lock()
|
||||||
|
src.size = 0
|
||||||
|
src.rangeEnabled = false
|
||||||
|
src.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
writeHeaders(w, meta, start, end, ranged)
|
writeHeaders(w, meta, start, end, ranged)
|
||||||
if ranged {
|
if ranged {
|
||||||
w.WriteHeader(http.StatusPartialContent)
|
w.WriteHeader(http.StatusPartialContent)
|
||||||
}
|
}
|
||||||
|
if producer, ok := reader.(production.Reader); ok && producer.Production() != nil {
|
||||||
|
if flusher, ok := w.(http.Flusher); ok {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
limit := int64(-1)
|
limit := int64(-1)
|
||||||
if end >= start {
|
if end >= start {
|
||||||
limit = end - start + 1
|
limit = end - start + 1
|
||||||
@@ -675,3 +701,21 @@ func randomID(size int) (string, error) {
|
|||||||
}
|
}
|
||||||
return hex.EncodeToString(buf), nil
|
return hex.EncodeToString(buf), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Production returns the active opener's input counter, if it generates output.
|
||||||
|
func (r *Registry) Production(raw string) production.Source {
|
||||||
|
if r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
src, err := r.get(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
src.mu.Lock()
|
||||||
|
reader := src.producer
|
||||||
|
src.mu.Unlock()
|
||||||
|
if reader == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return reader.Production()
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/GopeedLab/gopeed/internal/tempfiles"
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -9,6 +10,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Controller struct {
|
type Controller struct {
|
||||||
|
TempFiles *tempfiles.Scope
|
||||||
|
ManagedProduction func(string) bool
|
||||||
|
TempDir string
|
||||||
GetConfig func(v any)
|
GetConfig func(v any)
|
||||||
GetProxy func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error)
|
GetProxy func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error)
|
||||||
FileController
|
FileController
|
||||||
|
|||||||
@@ -106,7 +106,13 @@ func (s *SchemeFilter) Match(uri string) bool {
|
|||||||
case FilterTypeUrl:
|
case FilterTypeUrl:
|
||||||
return strings.HasPrefix(uriUpper, patternUpper+":")
|
return strings.HasPrefix(uriUpper, patternUpper+":")
|
||||||
case FilterTypeFile:
|
case FilterTypeFile:
|
||||||
return strings.HasSuffix(uriUpper, "."+patternUpper)
|
// Strip query and fragment before matching so URLs like
|
||||||
|
// https://example.com/video.m3u8?sign=abc still route by extension.
|
||||||
|
clean := uri
|
||||||
|
if idx := strings.IndexAny(clean, "?#"); idx >= 0 {
|
||||||
|
clean = clean[:idx]
|
||||||
|
}
|
||||||
|
return strings.HasSuffix(strings.ToUpper(clean), "."+patternUpper)
|
||||||
case FilterTypeBase64:
|
case FilterTypeBase64:
|
||||||
return strings.HasPrefix(uriUpper, "DATA:"+patternUpper+";BASE64,")
|
return strings.HasPrefix(uriUpper, "DATA:"+patternUpper+";BASE64,")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Package production associates generated resources with their input progress.
|
||||||
|
package production
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Source counts unique input bytes and total received bytes (including refetches).
|
||||||
|
// Implementations must support concurrent snapshots.
|
||||||
|
type Source interface {
|
||||||
|
Progress() (downloaded, received int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reader resolves its optional producer before response headers are sent.
|
||||||
|
// A nil producer preserves ordinary Blob behavior.
|
||||||
|
type Reader interface {
|
||||||
|
Production() Source
|
||||||
|
WaitProduction(context.Context) error
|
||||||
|
}
|
||||||
|
type Registry struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sources map[string]Source
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Set(id string, source Source) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.sources == nil {
|
||||||
|
r.sources = make(map[string]Source)
|
||||||
|
}
|
||||||
|
r.sources[id] = source
|
||||||
|
}
|
||||||
|
func (r *Registry) Get(id string) Source { r.mu.Lock(); defer r.mu.Unlock(); return r.sources[id] }
|
||||||
|
func (r *Registry) Remove(id string) { r.mu.Lock(); defer r.mu.Unlock(); delete(r.sources, id) }
|
||||||
@@ -399,7 +399,10 @@ func (f *Fetcher) Wait() (err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !selected {
|
if !selected {
|
||||||
util.SafeRemove(filepath.Join(f.meta.Opts.Path, f.meta.Res.Name, file.Path()))
|
root, name := torrentFileLayout(f.torrent.Info(), file.FileInfo())
|
||||||
|
if err := removeUnselectedFile(f.meta.Opts.Path, root, name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -409,6 +412,62 @@ func (f *Fetcher) Wait() (err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Match the file storage layout, including its NoName convention for rootless
|
||||||
|
// torrents. File.Path() always prepends BestName, even when storage omits it.
|
||||||
|
func torrentFileLayout(info *metainfo.Info, file metainfo.FileInfo) (root, name string) {
|
||||||
|
parts := file.BestPath()
|
||||||
|
if info.BestName() != metainfo.NoName {
|
||||||
|
if len(parts) > 0 {
|
||||||
|
root = info.BestName()
|
||||||
|
}
|
||||||
|
parts = append([]string{info.BestName()}, parts...)
|
||||||
|
}
|
||||||
|
// A v2 file tree can itself have a named root without an extra name wrapper.
|
||||||
|
if root == "" && info.HasV2() && len(info.FileTree.Dir) == 1 {
|
||||||
|
for dir, tree := range info.FileTree.Dir {
|
||||||
|
if tree.IsDir() {
|
||||||
|
root = dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return root, filepath.Join(parts...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve the actual torrent root, or the download directory for rootless
|
||||||
|
// torrents. Only prune empty directories strictly below that boundary.
|
||||||
|
func removeUnselectedFile(downloadDir, torrentRoot, torrentPath string) error {
|
||||||
|
if !filepath.IsLocal(torrentPath) || (torrentRoot != "" && !filepath.IsLocal(torrentRoot)) {
|
||||||
|
return fmt.Errorf("invalid torrent file path %q", torrentPath)
|
||||||
|
}
|
||||||
|
root := filepath.Join(downloadDir, torrentRoot)
|
||||||
|
name := filepath.Join(downloadDir, torrentPath)
|
||||||
|
rel, err := filepath.Rel(root, name)
|
||||||
|
if err != nil || rel == "." || !filepath.IsLocal(rel) {
|
||||||
|
return fmt.Errorf("torrent file %q is outside cleanup root %q", name, root)
|
||||||
|
}
|
||||||
|
for _, candidate := range []string{name, name + ".part"} {
|
||||||
|
if err := util.SafeRemove(candidate); err != nil {
|
||||||
|
return fmt.Errorf("remove unselected torrent file %q: %w", candidate, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for dir := filepath.Dir(name); dir != root && dir != filepath.Dir(dir); dir = filepath.Dir(dir) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read torrent directory %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := util.SafeRemove(dir); err != nil {
|
||||||
|
return fmt.Errorf("remove empty torrent directory %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Fetcher) isDone() bool {
|
func (f *Fetcher) isDone() bool {
|
||||||
if f.meta.Opts == nil {
|
if f.meta.Opts == nil {
|
||||||
return false
|
return false
|
||||||
@@ -638,6 +697,10 @@ func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
|
|||||||
}
|
}
|
||||||
spec.Storage = storage.NewFileOpts(storage.NewFileClientOpts{
|
spec.Storage = storage.NewFileOpts(storage.NewFileClientOpts{
|
||||||
ClientBaseDir: cfg.DataDir,
|
ClientBaseDir: cfg.DataDir,
|
||||||
|
FilePathMaker: func(opts storage.FilePathMakerOpts) string {
|
||||||
|
_, name := torrentFileLayout(opts.Info, *opts.File)
|
||||||
|
return name
|
||||||
|
},
|
||||||
TorrentDirMaker: func(baseDir string, info *metainfo.Info, infoHash metainfo.Hash) string {
|
TorrentDirMaker: func(baseDir string, info *metainfo.Info, infoHash metainfo.Hash) string {
|
||||||
return f.meta.Opts.Path
|
return f.meta.Opts.Path
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
package bt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha1"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
"github.com/anacrolix/torrent"
|
||||||
|
"github.com/anacrolix/torrent/bencode"
|
||||||
|
"github.com/anacrolix/torrent/metainfo"
|
||||||
|
"github.com/anacrolix/torrent/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStartUsesSelectionSubmittedAfterResolve(t *testing.T) {
|
||||||
|
config := torrent.TestingConfig(t)
|
||||||
|
config.DataDir = t.TempDir()
|
||||||
|
config.DisableTCP = true
|
||||||
|
config.DisableUTP = true
|
||||||
|
c, err := torrent.NewClient(config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
tor, err := c.AddTorrentFromFile("testdata/test.torrent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f := &Fetcher{torrent: tor, meta: &fetcher.FetcherMeta{Opts: &base.Options{}}, data: &fetcherData{}}
|
||||||
|
f.torrentReady.Store(true)
|
||||||
|
f.updateRes()
|
||||||
|
// Resolve initializes all files; creation replaces the selection before Start.
|
||||||
|
f.meta.Opts = &base.Options{SelectFiles: []int{2}}
|
||||||
|
if err := f.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i, file := range tor.Files() {
|
||||||
|
want := torrent.PiecePriorityNone
|
||||||
|
if i == 2 {
|
||||||
|
want = torrent.PiecePriorityNormal
|
||||||
|
}
|
||||||
|
if file.Priority() != want {
|
||||||
|
t.Errorf("file %d priority = %v, want %v", i, file.Priority(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(f.data.Progress) != 1 {
|
||||||
|
t.Fatalf("progress tracks %d files, want 1", len(f.data.Progress))
|
||||||
|
}
|
||||||
|
if err := f.Pause(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tor.Files()[0].Priority() != torrent.PiecePriorityNone {
|
||||||
|
t.Fatal("resume selected an unselected file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitCleansUnselectedFilesFromSharedPiece(t *testing.T) {
|
||||||
|
for _, mode := range []string{"partial", "part only", "both paths", "all", "already removed", "remove error"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
keepPath := filepath.Join(dir, "bundle", "keep.txt")
|
||||||
|
skipPath := filepath.Join(dir, "bundle", "nested", "skip.txt")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(skipPath), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(keepPath, []byte("keep"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(skipPath, []byte("skip"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Both files occupy the same piece, so downloading keep.txt also writes skip.txt.
|
||||||
|
hash := sha1.Sum([]byte("keepskip"))
|
||||||
|
info := metainfo.Info{Name: "bundle", PieceLength: 16, Pieces: hash[:], Files: []metainfo.FileInfo{
|
||||||
|
{Length: 4, Path: []string{"keep.txt"}},
|
||||||
|
{Length: 4, Path: []string{"nested", "skip.txt"}},
|
||||||
|
}}
|
||||||
|
store := storage.NewFileOpts(storage.NewFileClientOpts{
|
||||||
|
ClientBaseDir: dir,
|
||||||
|
TorrentDirMaker: func(baseDir string, _ *metainfo.Info, _ metainfo.Hash) string { return baseDir },
|
||||||
|
})
|
||||||
|
defer store.Close()
|
||||||
|
config := torrent.TestingConfig(t)
|
||||||
|
config.DisableTCP = true
|
||||||
|
config.DisableUTP = true
|
||||||
|
c, err := torrent.NewClient(config)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
spec, err := torrent.TorrentSpecFromMetaInfoErr(&metainfo.MetaInfo{InfoBytes: bencode.MustMarshal(&info)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
spec.Storage = store
|
||||||
|
tor, _, err := c.AddTorrentSpec(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := tor.VerifyDataContext(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f := &Fetcher{torrent: tor, meta: &fetcher.FetcherMeta{Opts: &base.Options{Path: dir, SelectFiles: []int{0}}}, data: &fetcherData{}, torrentDropCtx: ctx}
|
||||||
|
f.torrentReady.Store(true)
|
||||||
|
f.updateRes()
|
||||||
|
if mode == "all" {
|
||||||
|
f.meta.Opts.SelectFiles = []int{0, 1}
|
||||||
|
}
|
||||||
|
if !f.isDone() {
|
||||||
|
t.Fatal("selected file is not complete")
|
||||||
|
}
|
||||||
|
if mode == "part only" || mode == "both paths" || mode == "all" {
|
||||||
|
if err := os.WriteFile(skipPath+".part", []byte("skip"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == "part only" || mode == "already removed" || mode == "remove error" {
|
||||||
|
if err := os.Remove(skipPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == "remove error" {
|
||||||
|
if err := os.Mkdir(skipPath, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(skipPath, "child"), []byte("preserve"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = f.Wait()
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
t.Fatal(ctx.Err())
|
||||||
|
}
|
||||||
|
if mode == "remove error" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("cleanup failure was silently ignored")
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(keepPath)
|
||||||
|
if err != nil || string(data) != "keep" {
|
||||||
|
t.Fatalf("selected file changed: %q, %v", data, err)
|
||||||
|
}
|
||||||
|
if mode == "all" {
|
||||||
|
if _, err := os.Stat(skipPath + ".part"); err != nil {
|
||||||
|
t.Fatalf("selected part file was removed: %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(skipPath)
|
||||||
|
if err != nil || string(data) != "skip" {
|
||||||
|
t.Fatalf("selected file changed: %q, %v", data, err)
|
||||||
|
}
|
||||||
|
} else if mode != "remove error" {
|
||||||
|
for _, removed := range []string{skipPath, skipPath + ".part", filepath.Dir(skipPath)} {
|
||||||
|
if _, err := os.Stat(removed); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("unselected file or empty directory remains: %s: %v", removed, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveUnselectedFilePrunesOnlyEmptyParents(t *testing.T) {
|
||||||
|
for _, preserveSibling := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprint(preserveSibling), func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
relative := filepath.Join("bundle", "nested", "deep", "skip.txt")
|
||||||
|
name := filepath.Join(root, relative)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(name), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(name+".part", []byte("partial"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sibling := filepath.Join(root, "bundle", "keep.txt")
|
||||||
|
if preserveSibling {
|
||||||
|
if err := os.WriteFile(sibling, []byte("keep"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := removeUnselectedFile(root, "bundle", relative); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "bundle", "nested")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("empty ancestors remain: %v", err)
|
||||||
|
}
|
||||||
|
if preserveSibling {
|
||||||
|
data, err := os.ReadFile(sibling)
|
||||||
|
if err != nil || string(data) != "keep" {
|
||||||
|
t.Fatalf("sibling changed: %q, %v", data, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(filepath.Join(root, "bundle")); err != nil || !info.IsDir() {
|
||||||
|
t.Fatalf("torrent root directory was removed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(root); err != nil {
|
||||||
|
t.Fatalf("download directory was removed: %v", err)
|
||||||
|
}
|
||||||
|
// A file directly inside the torrent root must not cause that root to be pruned.
|
||||||
|
rootFile := filepath.Join("bundle", "root.txt")
|
||||||
|
if err := os.WriteFile(filepath.Join(root, rootFile)+".part", []byte("partial"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := removeUnselectedFile(root, "bundle", rootFile); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "bundle")); err != nil {
|
||||||
|
t.Fatalf("torrent root directory was removed after root-level cleanup: %v", err)
|
||||||
|
}
|
||||||
|
// Repeating cleanup after the file and its subdirectories are gone is harmless.
|
||||||
|
if err := removeUnselectedFile(root, "bundle", relative); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupMatchesNamedAndRootlessStorage(t *testing.T) {
|
||||||
|
for _, version := range []int64{1, 2} {
|
||||||
|
for _, layout := range []string{"rootless", "wrapped", "file-tree-root"} {
|
||||||
|
if version == 1 && layout == "file-tree-root" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Run(fmt.Sprintf("v%d/%s", version, layout), func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
info := metainfo.Info{Name: metainfo.NoName, PieceLength: 16384}
|
||||||
|
root := dir
|
||||||
|
if layout == "wrapped" {
|
||||||
|
info.Name = "bundle"
|
||||||
|
root = filepath.Join(dir, "bundle")
|
||||||
|
}
|
||||||
|
if version == 1 {
|
||||||
|
info.Files = []metainfo.FileInfo{{Path: []string{"keep.txt"}}, {Path: []string{"nested", "deep", "skip.txt"}}}
|
||||||
|
} else {
|
||||||
|
info.MetaVersion = 2
|
||||||
|
info.FileTree = metainfo.FileTree{Dir: map[string]metainfo.FileTree{
|
||||||
|
"keep.txt": {},
|
||||||
|
"nested": {Dir: map[string]metainfo.FileTree{"deep": {Dir: map[string]metainfo.FileTree{"skip.txt": {}}}}},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if layout == "file-tree-root" {
|
||||||
|
info.FileTree = metainfo.FileTree{Dir: map[string]metainfo.FileTree{"tree-root": info.FileTree}}
|
||||||
|
root = filepath.Join(dir, "tree-root")
|
||||||
|
}
|
||||||
|
// Keep the library's default FilePathMaker here to independently verify
|
||||||
|
// that cleanup agrees with its real disk layout, including NoName.
|
||||||
|
store := storage.NewFileOpts(storage.NewFileClientOpts{
|
||||||
|
ClientBaseDir: dir,
|
||||||
|
TorrentDirMaker: func(baseDir string, _ *metainfo.Info, _ metainfo.Hash) string { return baseDir },
|
||||||
|
})
|
||||||
|
defer store.Close()
|
||||||
|
torrentStorage, err := store.OpenTorrent(context.Background(), &info, metainfo.Hash{1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer torrentStorage.Close()
|
||||||
|
skip := filepath.Join(root, "nested", "deep", "skip.txt")
|
||||||
|
if _, err := os.Stat(skip); err != nil {
|
||||||
|
t.Fatalf("unexpected storage layout: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(skip+".part", []byte("partial"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
files := info.UpvertedFiles()
|
||||||
|
boundary, name := torrentFileLayout(&info, files[1])
|
||||||
|
if err := removeUnselectedFile(dir, boundary, name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "nested")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("empty subtree remains: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "keep.txt")); err != nil {
|
||||||
|
t.Fatalf("selected file removed: %v", err)
|
||||||
|
}
|
||||||
|
// Also verify the boundary when the protected directory becomes empty.
|
||||||
|
if err := os.Remove(filepath.Join(root, "keep.txt")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, file := range files {
|
||||||
|
boundary, name := torrentFileLayout(&info, file)
|
||||||
|
if err := removeUnselectedFile(dir, boundary, name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stat, err := os.Stat(root); err != nil || !stat.IsDir() {
|
||||||
|
t.Fatalf("cleanup boundary was removed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package hls
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultSegmentConnections = 8
|
||||||
|
defaultMaxRetries = 5
|
||||||
|
defaultTimeoutSeconds = 30
|
||||||
|
|
||||||
|
// maxPrefetchSizeSegments caps the HEAD probing done at resolve time.
|
||||||
|
// Playlists larger than this skip size prefetch entirely.
|
||||||
|
maxPrefetchSizeSegments = 500
|
||||||
|
)
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
SegmentConnections int `json:"segmentConnections"`
|
||||||
|
MaxRetries int `json:"maxRetries"`
|
||||||
|
TimeoutSeconds int `json:"timeoutSeconds"`
|
||||||
|
PrefetchContentLength bool `json:"prefetchContentLength"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *config) normalize() {
|
||||||
|
if c.SegmentConnections <= 0 {
|
||||||
|
c.SegmentConnections = defaultSegmentConnections
|
||||||
|
}
|
||||||
|
if c.MaxRetries < 0 {
|
||||||
|
c.MaxRetries = defaultMaxRetries
|
||||||
|
}
|
||||||
|
if c.TimeoutSeconds <= 0 {
|
||||||
|
c.TimeoutSeconds = defaultTimeoutSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package hls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// decryptAES128 decrypts an AES-128-CBC encrypted segment and strips PKCS7
|
||||||
|
// padding. data length must be a multiple of the AES block size.
|
||||||
|
func decryptAES128(data, key, iv []byte) ([]byte, error) {
|
||||||
|
if len(key) != 16 {
|
||||||
|
return nil, fmt.Errorf("invalid AES-128 key size %d", len(key))
|
||||||
|
}
|
||||||
|
if len(iv) != 16 {
|
||||||
|
return nil, fmt.Errorf("invalid AES-128 IV size %d", len(iv))
|
||||||
|
}
|
||||||
|
if len(data) == 0 || len(data)%aes.BlockSize != 0 {
|
||||||
|
return nil, fmt.Errorf("invalid AES-128 data size %d", len(data))
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
plain := make([]byte, len(data))
|
||||||
|
cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, data)
|
||||||
|
return pkcs7Unpad(plain, aes.BlockSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty plaintext")
|
||||||
|
}
|
||||||
|
pad := int(data[len(data)-1])
|
||||||
|
if pad == 0 || pad > blockSize || pad > len(data) {
|
||||||
|
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||||
|
}
|
||||||
|
for _, b := range data[len(data)-pad:] {
|
||||||
|
if int(b) != pad {
|
||||||
|
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data[:len(data)-pad], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sequenceIV derives the default per-segment IV mandated by RFC 8216: the
|
||||||
|
// segment media sequence number as a 16-byte big-endian integer.
|
||||||
|
func sequenceIV(sequence int64) []byte {
|
||||||
|
iv := make([]byte, 16)
|
||||||
|
iv[8] = byte(sequence >> 56)
|
||||||
|
iv[9] = byte(sequence >> 48)
|
||||||
|
iv[10] = byte(sequence >> 40)
|
||||||
|
iv[11] = byte(sequence >> 32)
|
||||||
|
iv[12] = byte(sequence >> 24)
|
||||||
|
iv[13] = byte(sequence >> 16)
|
||||||
|
iv[14] = byte(sequence >> 8)
|
||||||
|
iv[15] = byte(sequence)
|
||||||
|
return iv
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package hls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pkcs7Pad(data []byte, blockSize int) []byte {
|
||||||
|
pad := blockSize - len(data)%blockSize
|
||||||
|
return append(data, bytes.Repeat([]byte{byte(pad)}, pad)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptAES128Roundtrip(t *testing.T) {
|
||||||
|
key := make([]byte, 16)
|
||||||
|
iv := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := rand.Read(iv); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
plain := []byte("hello hls segment payload, padded to block size!")
|
||||||
|
padded := pkcs7Pad(plain, aes.BlockSize)
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
encrypted := make([]byte, len(padded))
|
||||||
|
cipher.NewCBCEncrypter(block, iv).CryptBlocks(encrypted, padded)
|
||||||
|
|
||||||
|
got, err := decryptAES128(encrypted, key, iv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, plain) {
|
||||||
|
t.Errorf("roundtrip mismatch: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecryptAES128InvalidInput(t *testing.T) {
|
||||||
|
if _, err := decryptAES128([]byte{1, 2, 3}, make([]byte, 16), make([]byte, 16)); err == nil {
|
||||||
|
t.Error("non-block-aligned data should fail")
|
||||||
|
}
|
||||||
|
if _, err := decryptAES128(make([]byte, 16), make([]byte, 15), make([]byte, 16)); err == nil {
|
||||||
|
t.Error("short key should fail")
|
||||||
|
}
|
||||||
|
if _, err := decryptAES128(make([]byte, 16), make([]byte, 16), make([]byte, 16)); err == nil {
|
||||||
|
t.Error("all-zero block has invalid padding and should fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSequenceIV(t *testing.T) {
|
||||||
|
iv := sequenceIV(1)
|
||||||
|
if len(iv) != 16 || iv[15] != 1 {
|
||||||
|
t.Errorf("sequence 1 IV wrong: %v", iv)
|
||||||
|
}
|
||||||
|
iv = sequenceIV(0x0102030405060708)
|
||||||
|
for i, want := range []byte{1, 2, 3, 4, 5, 6, 7, 8} {
|
||||||
|
if iv[8+i] != want {
|
||||||
|
t.Errorf("sequence IV byte %d: want %d got %d", i, want, iv[8+i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Package hls implements native HTTP Live Streaming downloads for m3u8
|
||||||
|
// playlists. A task produces exactly one merged, playable output file:
|
||||||
|
// MPEG-2 TS segments are concatenated as-is, fMP4 streams are merged as
|
||||||
|
// init section followed by the media fragments, and AES-128 encrypted
|
||||||
|
// segments are decrypted before they are stored.
|
||||||
|
//
|
||||||
|
// Supported subset:
|
||||||
|
// - VOD media playlists (EXT-X-ENDLIST present) and master playlists whose
|
||||||
|
// selected variant carries its own audio
|
||||||
|
// - AES-128 with the identity KEYFORMAT, explicit IV or media-sequence
|
||||||
|
// derived IV, including key rotation mid-playlist
|
||||||
|
// - EXT-X-BYTERANGE (including offset inheritance) and fMP4 init sections
|
||||||
|
// via a single, constant EXT-X-MAP
|
||||||
|
//
|
||||||
|
// Everything the merge pipeline cannot reproduce faithfully is rejected with
|
||||||
|
// an explicit error instead of silently producing a broken output file:
|
||||||
|
// - live streams (no EXT-X-ENDLIST) and SAMPLE-AES / DRM encryption
|
||||||
|
// - EXT-X-GAP (would leave a hole) and EXT-X-DISCONTINUITY (broken timeline)
|
||||||
|
// - changing init sections (a repeated EXT-X-MAP is compared by all its
|
||||||
|
// attributes, not just the URI) and encrypted EXT-X-MAP without an
|
||||||
|
// explicit IV
|
||||||
|
// - separate audio renditions referenced by the selected variant (the
|
||||||
|
// merged output would have no audio) and non-identity KEYFORMATs
|
||||||
|
package hls
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,100 @@
|
|||||||
|
package hls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FetcherManager struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) Name() string {
|
||||||
|
return "hls"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filters registers the .m3u8 extension. The manager must be registered BEFORE
|
||||||
|
// the http manager in the downloader config: protocol routing picks the first
|
||||||
|
// manager whose filter matches, and the http filter claims every http(s) URL.
|
||||||
|
func (fm *FetcherManager) Filters() []*fetcher.SchemeFilter {
|
||||||
|
return []*fetcher.SchemeFilter{
|
||||||
|
{
|
||||||
|
Type: fetcher.FilterTypeFile,
|
||||||
|
Pattern: "M3U8",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) Build() fetcher.Fetcher {
|
||||||
|
return &Fetcher{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) ParseName(u string) string {
|
||||||
|
parsed, err := url.Parse(u)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
name := path.Base(parsed.Path)
|
||||||
|
name = strings.TrimSuffix(name, path.Ext(name))
|
||||||
|
if name == "" || name == "/" || name == "." {
|
||||||
|
name = parsed.Hostname()
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) AutoRename() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) DefaultConfig() any {
|
||||||
|
return &config{
|
||||||
|
SegmentConnections: defaultSegmentConnections,
|
||||||
|
MaxRetries: defaultMaxRetries,
|
||||||
|
TimeoutSeconds: defaultTimeoutSeconds,
|
||||||
|
PrefetchContentLength: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
|
||||||
|
_f := f.(*Fetcher)
|
||||||
|
_f.mu.Lock()
|
||||||
|
defer _f.mu.Unlock()
|
||||||
|
if _f.state == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// The engine's periodic checkpoint calls Store without holding the task
|
||||||
|
// lock and serializes the returned value afterwards, while Resolve/Patch/
|
||||||
|
// Start mutate the live state under the fetcher mutex: return a snapshot
|
||||||
|
// copy taken under that mutex, mirroring the HTTP protocol's Store.
|
||||||
|
return cloneState(_f.state), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneState(state *fetcherState) *fetcherState {
|
||||||
|
cloned := *state
|
||||||
|
cloned.Segments = make([]*Segment, len(state.Segments))
|
||||||
|
for i, seg := range state.Segments {
|
||||||
|
s := *seg
|
||||||
|
cloned.Segments[i] = &s
|
||||||
|
}
|
||||||
|
return &cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {
|
||||||
|
return &fetcherState{}, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher {
|
||||||
|
fb := &FetcherManager{}
|
||||||
|
restored := fb.Build().(*Fetcher)
|
||||||
|
restored.meta = meta
|
||||||
|
if state, ok := v.(*fetcherState); ok && state.MediaURL != "" {
|
||||||
|
restored.state = state
|
||||||
|
}
|
||||||
|
// Setup is invoked by the engine (restoreFetcher) after this builder
|
||||||
|
// returns; ReqExtra parsing happens there as well.
|
||||||
|
return restored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,100 @@
|
|||||||
|
package hls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/internal/httpclient"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
|
const maxPlaylistSize = 10 << 20 // 10MB, playlists are small text files
|
||||||
|
|
||||||
|
func (f *Fetcher) timeout() time.Duration {
|
||||||
|
return time.Duration(f.config.TimeoutSeconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildClient builds an HTTP client honoring the task proxy and TLS settings.
|
||||||
|
// The client has no total timeout on purpose: large segments over slow links
|
||||||
|
// must not be aborted; body stalls are handled by stallReader instead. It is
|
||||||
|
// only called while the fetcher is quiescent (Resolve/Start), never from
|
||||||
|
// inside running worker goroutines.
|
||||||
|
func (f *Fetcher) buildClient() *http.Client {
|
||||||
|
var proxy *base.RequestProxy
|
||||||
|
skipVerify := false
|
||||||
|
if req := f.meta.Req; req != nil {
|
||||||
|
proxy = req.Proxy
|
||||||
|
skipVerify = req.SkipVerifyCert
|
||||||
|
}
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
client, err := httpclient.NewClient(httpclient.Options{
|
||||||
|
Client: httpclient.ClientOptions{
|
||||||
|
Jar: jar,
|
||||||
|
},
|
||||||
|
Transport: httpclient.TransportOptions{
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: f.timeout(),
|
||||||
|
}).DialContext,
|
||||||
|
Proxy: f.Ctl.GetProxy(proxy),
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
InsecureSkipVerify: skipVerify,
|
||||||
|
},
|
||||||
|
TLSHandshakeTimeout: f.timeout(),
|
||||||
|
},
|
||||||
|
Impersonation: httpclient.ImpersonationOptions{
|
||||||
|
Mode: httpclient.ImpersonationAuto,
|
||||||
|
Session: f.impSession,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("build HTTP client: %v", err))
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequest builds a request with task extra headers, the default user
|
||||||
|
// agent and an optional byte range. extra may be nil.
|
||||||
|
func buildRequest(ctx context.Context, extra *ReqExtra, method, u string, body io.Reader, byterange *ByteRange) (*http.Request, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, u, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if extra != nil {
|
||||||
|
for key, value := range extra.Header {
|
||||||
|
req.Header.Set(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.Header.Get("User-Agent") == "" {
|
||||||
|
req.Header.Set("User-Agent", defaultUserAgent)
|
||||||
|
}
|
||||||
|
if byterange != nil {
|
||||||
|
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", byterange.Offset, byterange.Offset+byterange.Length-1))
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stallReader aborts reads that see no data for the configured timeout.
|
||||||
|
type stallReader struct {
|
||||||
|
rc io.ReadCloser
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stallReader) Read(p []byte) (int, error) {
|
||||||
|
timer := time.AfterFunc(r.timeout, func() {
|
||||||
|
r.rc.Close()
|
||||||
|
})
|
||||||
|
defer timer.Stop()
|
||||||
|
return r.rc.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *stallReader) Close() error {
|
||||||
|
return r.rc.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package hls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// journalFile is the persisted completed-segment ledger. It is bound to the
|
||||||
|
// exact download plan: a journal whose fingerprint does not match the current
|
||||||
|
// plan is discarded instead of being applied to the wrong segments.
|
||||||
|
type journalFile struct {
|
||||||
|
StagingID string `json:"stagingID"`
|
||||||
|
PlanHash string `json:"planHash"`
|
||||||
|
SegmentCount int `json:"segmentCount"`
|
||||||
|
Sizes map[string]int64 `json:"sizes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomStagingID returns a random identifier used to give every task its own
|
||||||
|
// staging folder, stable across app restarts through the persisted state.
|
||||||
|
func randomStagingID() string {
|
||||||
|
buf := make([]byte, 6)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
// crypto/rand failing is catastrophic anyway; fall back to address
|
||||||
|
// entropy rather than crashing the fetch.
|
||||||
|
return fmt.Sprintf("%x", sha1.Sum([]byte(fmt.Sprintf("%p", buf))))[:12]
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// planFingerprint hashes everything that defines the download plan: the media
|
||||||
|
// URL, the ordered segments with their sequence numbers, byte ranges, keys
|
||||||
|
// and init markers. Two tasks resolve to different fingerprints even when
|
||||||
|
// they point at the same playlist URL.
|
||||||
|
func planFingerprint(state *fetcherState) string {
|
||||||
|
h := sha1.New()
|
||||||
|
io.WriteString(h, state.MediaURL+"\n")
|
||||||
|
io.WriteString(h, strconv.Itoa(len(state.Segments))+"\n")
|
||||||
|
for _, seg := range state.Segments {
|
||||||
|
fmt.Fprintf(h, "%d|%s|", seg.Sequence, seg.URI)
|
||||||
|
if seg.Byterange != nil {
|
||||||
|
fmt.Fprintf(h, "%d@%d|", seg.Byterange.Offset, seg.Byterange.Length)
|
||||||
|
}
|
||||||
|
if seg.Key != nil {
|
||||||
|
// The IV is part of the plan: same URI with a rotated IV would
|
||||||
|
// silently decrypt to garbage if the journal were reused.
|
||||||
|
fmt.Fprintf(h, "key=%s iv=%x|", seg.Key.URI, seg.Key.IV)
|
||||||
|
}
|
||||||
|
if seg.IsInit {
|
||||||
|
io.WriteString(h, "init|")
|
||||||
|
}
|
||||||
|
io.WriteString(h, "\n")
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil))[:16]
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadJournal reads the journal of a run, discarding anything that cannot be
|
||||||
|
// proven to belong to the current plan: foreign or legacy journals, corrupted
|
||||||
|
// files, and entries whose recorded size no longer matches the plan.
|
||||||
|
func loadJournal(run *hlsRun) error {
|
||||||
|
data, err := os.ReadFile(run.journalPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var file journalFile
|
||||||
|
if err := json.Unmarshal(data, &file); err != nil {
|
||||||
|
// A broken or legacy journal must not kill a restartable download.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if file.StagingID != run.stagingID || file.PlanHash != run.planHash || file.SegmentCount != len(run.segs) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for key, size := range file.Sizes {
|
||||||
|
idx, err := strconv.ParseInt(key, 10, 64)
|
||||||
|
if err != nil || idx < 0 || idx >= int64(len(run.segs)) || size <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
run.journal[idx] = size
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveJournal atomically rewrites the journal through a temp file + rename so
|
||||||
|
// a crash never leaves a half-written ledger behind.
|
||||||
|
func saveJournal(run *hlsRun) error {
|
||||||
|
file := journalFile{
|
||||||
|
StagingID: run.stagingID,
|
||||||
|
PlanHash: run.planHash,
|
||||||
|
SegmentCount: len(run.segs),
|
||||||
|
Sizes: make(map[string]int64, len(run.journal)),
|
||||||
|
}
|
||||||
|
for idx, size := range run.journal {
|
||||||
|
file.Sizes[strconv.FormatInt(idx, 10)] = size
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := run.journalPath + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, 0666); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, run.journalPath)
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package hls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The validators below check real media structure, not just byte
|
||||||
|
// concatenation: TS packets are verified on the 188 byte grid with
|
||||||
|
// continuity-counter rules, PAT/PMT presence and PES start codes; fMP4
|
||||||
|
// output is verified by walking the ISO-BMFF box hierarchy.
|
||||||
|
|
||||||
|
func be32(b []byte) int {
|
||||||
|
return int(b[0])<<24 | int(b[1])<<16 | int(b[2])<<8 | int(b[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateTS asserts structural validity of a merged MPEG-2 TS stream.
|
||||||
|
func validateTS(t *testing.T, data []byte) {
|
||||||
|
t.Helper()
|
||||||
|
if len(data) == 0 || len(data)%188 != 0 {
|
||||||
|
t.Fatalf("TS size %d is not a positive multiple of 188", len(data))
|
||||||
|
}
|
||||||
|
type pidState struct{ cc int }
|
||||||
|
ccByPID := map[int]int{}
|
||||||
|
patSeen, pmtSeen, pesSeen := false, false, false
|
||||||
|
for off := 0; off < len(data); off += 188 {
|
||||||
|
pkt := data[off : off+188]
|
||||||
|
if pkt[0] != 0x47 {
|
||||||
|
t.Fatalf("bad TS sync byte at offset %d", off)
|
||||||
|
}
|
||||||
|
pid := (int(pkt[1]&0x1F) << 8) | int(pkt[2])
|
||||||
|
pusi := pkt[1]&0x40 != 0
|
||||||
|
afc := (pkt[3] >> 4) & 0x3
|
||||||
|
if afc == 0 {
|
||||||
|
continue // reserved
|
||||||
|
}
|
||||||
|
idx := 4
|
||||||
|
if afc&0x2 != 0 { // adaptation field present
|
||||||
|
afLen := int(pkt[4])
|
||||||
|
if afLen > 0 && pkt[5]&0x80 != 0 {
|
||||||
|
// discontinuity_indicator: restart the CC expectation
|
||||||
|
delete(ccByPID, pid)
|
||||||
|
}
|
||||||
|
idx += 1 + afLen
|
||||||
|
if afc == 2 {
|
||||||
|
continue // adaptation only, no payload: no CC rule
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx > 188 {
|
||||||
|
t.Fatalf("adaptation field overruns packet at offset %d", off)
|
||||||
|
}
|
||||||
|
if pid == 0x1FFF {
|
||||||
|
continue // null packet
|
||||||
|
}
|
||||||
|
payload := pkt[idx:]
|
||||||
|
if pusi && len(payload) >= 3 && pid != 0 && payload[0] == 0 && payload[1] == 0 && payload[2] == 1 {
|
||||||
|
pesSeen = true
|
||||||
|
}
|
||||||
|
if pid == 0 {
|
||||||
|
if ptr := int(payload[0]); 1+ptr < len(payload) && payload[1+ptr] == 0x00 {
|
||||||
|
patSeen = true // program_association_section table_id
|
||||||
|
}
|
||||||
|
} else if pusi && len(payload) >= 2 && payload[0] == 0 && payload[1] == 0x02 {
|
||||||
|
pmtSeen = true // program_map_section table_id
|
||||||
|
}
|
||||||
|
cc := int(pkt[3] & 0x0F)
|
||||||
|
prev, ok := ccByPID[pid]
|
||||||
|
if !ok {
|
||||||
|
ccByPID[pid] = cc
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// CC increments by one per payload-bearing packet; a repeated value
|
||||||
|
// models the single legal duplicate packet.
|
||||||
|
if cc != (prev+1)%16 && cc != prev {
|
||||||
|
t.Fatalf("continuity error on PID %d: %d after %d at offset %d", pid, cc, prev, off)
|
||||||
|
}
|
||||||
|
ccByPID[pid] = cc
|
||||||
|
}
|
||||||
|
if !patSeen {
|
||||||
|
t.Error("no PAT found in merged TS")
|
||||||
|
}
|
||||||
|
if !pmtSeen {
|
||||||
|
t.Error("no PMT found in merged TS")
|
||||||
|
}
|
||||||
|
if !pesSeen {
|
||||||
|
t.Error("no PES start code found in merged TS")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// walkBoxes iterates the top-level ISO-BMFF boxes of data[off:end].
|
||||||
|
func walkBoxes(t *testing.T, data []byte, off, end int) []string {
|
||||||
|
t.Helper()
|
||||||
|
var types []string
|
||||||
|
for off+8 <= end {
|
||||||
|
size := be32(data[off:])
|
||||||
|
typ := string(data[off+4 : off+8])
|
||||||
|
if size < 8 || off+size > end {
|
||||||
|
t.Fatalf("invalid box %q at offset %d (size %d)", typ, off, size)
|
||||||
|
}
|
||||||
|
types = append(types, typ)
|
||||||
|
off += size
|
||||||
|
}
|
||||||
|
return types
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateFMP4 asserts the merged fMP4 output is init section followed by
|
||||||
|
// well-formed moof+mdat fragments.
|
||||||
|
func validateFMP4(t *testing.T, merged []byte, wantFragments int) {
|
||||||
|
t.Helper()
|
||||||
|
types := walkBoxes(t, merged, 0, len(merged))
|
||||||
|
hasFtyp, hasMoov := false, false
|
||||||
|
fragments := 0
|
||||||
|
expectFragment := false
|
||||||
|
for i, typ := range types {
|
||||||
|
switch typ {
|
||||||
|
case "ftyp":
|
||||||
|
if i != 0 {
|
||||||
|
t.Errorf("ftyp not at stream start")
|
||||||
|
}
|
||||||
|
hasFtyp = true
|
||||||
|
case "moov":
|
||||||
|
if i != 1 {
|
||||||
|
t.Errorf("moov must follow ftyp, found at index %d", i)
|
||||||
|
}
|
||||||
|
hasMoov = true
|
||||||
|
case "styp":
|
||||||
|
expectFragment = true
|
||||||
|
case "moof":
|
||||||
|
if !hasMoov {
|
||||||
|
t.Errorf("fragment before moov")
|
||||||
|
}
|
||||||
|
expectFragment = true
|
||||||
|
case "mdat":
|
||||||
|
if !expectFragment {
|
||||||
|
t.Errorf("mdat outside of a fragment at index %d", i)
|
||||||
|
}
|
||||||
|
expectFragment = false
|
||||||
|
fragments++
|
||||||
|
case "mfra":
|
||||||
|
// optional trailer
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected top-level box %q", typ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasFtyp || !hasMoov {
|
||||||
|
t.Error("merged fMP4 missing ftyp/moov init section")
|
||||||
|
}
|
||||||
|
if fragments != wantFragments {
|
||||||
|
t.Errorf("want %d fragments in merged fMP4, got %d", wantFragments, fragments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaFixture(t *testing.T, name string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile("testdata/media/" + name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func serveFixtureServer(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
fs := http.FileServer(http.Dir("testdata/media"))
|
||||||
|
mux.Handle("/", fs)
|
||||||
|
return httptest.NewServer(mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHLSRealMedia_TS(t *testing.T) {
|
||||||
|
seg0 := mediaFixture(t, "sample-00.ts")
|
||||||
|
seg1 := mediaFixture(t, "sample-01.ts")
|
||||||
|
playlist := "#EXTM3U\n" +
|
||||||
|
"#EXTINF:0.5,\nsample-00.ts\n" +
|
||||||
|
"#EXTINF:0.5,\nsample-01.ts\n" +
|
||||||
|
"#EXT-X-ENDLIST\n"
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(playlist))
|
||||||
|
})
|
||||||
|
mux.Handle("/", http.FileServer(http.Dir("testdata/media")))
|
||||||
|
server := httptest.NewServer(mux)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
f := newTestFetcher(t, testConfig())
|
||||||
|
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.Wait(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := readOutput(t, f)
|
||||||
|
if !bytes.Equal(got, append(append([]byte{}, seg0...), seg1...)) {
|
||||||
|
t.Fatal("merged TS is not the exact concatenation of the segments")
|
||||||
|
}
|
||||||
|
validateTS(t, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHLSRealMedia_FMP4(t *testing.T) {
|
||||||
|
init := mediaFixture(t, "fmp4-init.mp4")
|
||||||
|
frag1 := mediaFixture(t, "fmp4-seg1.m4s")
|
||||||
|
frag2 := mediaFixture(t, "fmp4-seg2.m4s")
|
||||||
|
frag3 := mediaFixture(t, "fmp4-seg3.m4s")
|
||||||
|
playlist := "#EXTM3U\n" +
|
||||||
|
"#EXT-X-MAP:URI=\"fmp4-init.mp4\"\n" +
|
||||||
|
"#EXTINF:0.5,\nfmp4-seg1.m4s\n" +
|
||||||
|
"#EXTINF:0.5,\nfmp4-seg2.m4s\n" +
|
||||||
|
"#EXTINF:0.5,\nfmp4-seg3.m4s\n" +
|
||||||
|
"#EXT-X-ENDLIST\n"
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(playlist))
|
||||||
|
})
|
||||||
|
mux.Handle("/", http.FileServer(http.Dir("testdata/media")))
|
||||||
|
server := httptest.NewServer(mux)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
f := newTestFetcher(t, testConfig())
|
||||||
|
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.Wait(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := readOutput(t, f)
|
||||||
|
want := append(append(append(append([]byte{}, init...), frag1...), frag2...), frag3...)
|
||||||
|
if !bytes.Equal(got, want) {
|
||||||
|
t.Fatal("merged fMP4 is not the exact concatenation of init + fragments")
|
||||||
|
}
|
||||||
|
validateFMP4(t, got, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHLSRealMedia_AES128(t *testing.T) {
|
||||||
|
keys := [][]byte{[]byte("key-A-0123456789"), []byte("key-B-9876543210")}
|
||||||
|
iv := []byte("iv-1337-01234567")
|
||||||
|
plain := [][]byte{mediaFixture(t, "sample-00.ts"), mediaFixture(t, "sample-01.ts")}
|
||||||
|
|
||||||
|
playlist := "#EXTM3U\n" +
|
||||||
|
"#EXT-X-MEDIA-SEQUENCE:0\n" +
|
||||||
|
"#EXT-X-KEY:METHOD=AES-128,URI=\"key-0.bin\",IV=0x" + hexEncode(iv) + "\n" +
|
||||||
|
"#EXTINF:0.5,\nenc-0.ts\n" +
|
||||||
|
"#EXT-X-KEY:METHOD=AES-128,URI=\"key-1.bin\"\n" +
|
||||||
|
"#EXTINF:0.5,\nenc-1.ts\n" +
|
||||||
|
"#EXT-X-ENDLIST\n"
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(playlist))
|
||||||
|
})
|
||||||
|
for i, key := range keys {
|
||||||
|
k := key
|
||||||
|
mux.HandleFunc(fmt.Sprintf("/key-%d.bin", i), func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write(k)
|
||||||
|
})
|
||||||
|
// Segment 0 has an explicit IV, segment 1 derives it from its media
|
||||||
|
// sequence number (1).
|
||||||
|
enc := encryptSegment(plain[i], k, map[bool][]byte{true: iv, false: sequenceIV(int64(i))}[i == 0])
|
||||||
|
mux.HandleFunc(fmt.Sprintf("/enc-%d.ts", i), func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write(enc)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(mux)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
f := newTestFetcher(t, testConfig())
|
||||||
|
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if f.meta.Res.Size != 0 {
|
||||||
|
t.Errorf("encrypted plan must report indeterminate size, got %d", f.meta.Res.Size)
|
||||||
|
}
|
||||||
|
if err := f.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.Wait(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := readOutput(t, f)
|
||||||
|
if !bytes.Equal(got, append(append([]byte{}, plain[0]...), plain[1]...)) {
|
||||||
|
t.Fatal("decrypted output mismatch")
|
||||||
|
}
|
||||||
|
validateTS(t, got)
|
||||||
|
if p := f.Progress(); p.TotalDownloaded() != int64(len(got)) {
|
||||||
|
t.Errorf("final progress %d != merged size %d", p.TotalDownloaded(), len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
package hls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Variant is one rendition entry of a master playlist.
|
||||||
|
type Variant struct {
|
||||||
|
URI string
|
||||||
|
Bandwidth int64
|
||||||
|
AvgBandwidth int64
|
||||||
|
Resolution string
|
||||||
|
Codecs string
|
||||||
|
Name string
|
||||||
|
AudioGroup string // AUDIO attribute: group id of the audio renditions
|
||||||
|
}
|
||||||
|
|
||||||
|
// MediaRendition is one EXT-X-MEDIA entry of a master playlist. Only the
|
||||||
|
// fields needed to detect unsupported track layouts are kept.
|
||||||
|
type MediaRendition struct {
|
||||||
|
Type string
|
||||||
|
GroupID string
|
||||||
|
URI string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MasterPlaylist is a parsed master playlist with its rendition groups.
|
||||||
|
type MasterPlaylist struct {
|
||||||
|
Variants []*Variant
|
||||||
|
Rendition map[string][]*MediaRendition // group id -> all EXT-X-MEDIA members
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key is the decryption key applied to the segments that follow its tag, until
|
||||||
|
// the next EXT-X-KEY tag or the end of the playlist.
|
||||||
|
type Key struct {
|
||||||
|
Method string
|
||||||
|
URI string // absolute URI
|
||||||
|
IV []byte // explicit IV; nil means derive from the media sequence number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ByteRange is an EXT-X-BYTERANGE sub range of the segment resource.
|
||||||
|
type ByteRange struct {
|
||||||
|
Length int64
|
||||||
|
Offset int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segment is one downloadable piece of a media playlist. The init section of
|
||||||
|
// an fMP4 stream is represented as a segment with IsInit set.
|
||||||
|
type Segment struct {
|
||||||
|
URI string
|
||||||
|
Sequence int64 // media sequence number, used to derive the default AES IV
|
||||||
|
Byterange *ByteRange
|
||||||
|
Key *Key // nil means unencrypted
|
||||||
|
IsInit bool
|
||||||
|
Gap bool
|
||||||
|
Duration float64
|
||||||
|
Size int64 // exact size in bytes when known (byterange / HEAD probe), 0 when unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media is a parsed media playlist.
|
||||||
|
type Media struct {
|
||||||
|
Segments []*Segment
|
||||||
|
Version int
|
||||||
|
Live bool // no EXT-X-ENDLIST tag
|
||||||
|
IsFMP4 bool // playlist declares an init section via EXT-X-MAP
|
||||||
|
Discontinuity bool // playlist contains EXT-X-DISCONTINUITY
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyMethodNone = "NONE"
|
||||||
|
keyMethodAES128 = "AES-128"
|
||||||
|
keyMethodSampleAES = "SAMPLE-AES"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsMasterPlaylist reports whether the content looks like a master playlist.
|
||||||
|
func IsMasterPlaylist(content string) bool {
|
||||||
|
return strings.Contains(content, "#EXT-X-STREAM-INF")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateM3U8Header(content string) error {
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line != "#EXTM3U" {
|
||||||
|
return errors.New("content is not a valid m3u8 playlist")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("empty m3u8 playlist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMaster parses a master playlist and returns all variants.
|
||||||
|
func ParseMaster(content string, baseURL *url.URL) ([]*Variant, error) {
|
||||||
|
master, err := ParseMasterPlaylist(content, baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return master.Variants, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMasterPlaylist parses a master playlist including its EXT-X-MEDIA
|
||||||
|
// rendition groups.
|
||||||
|
func ParseMasterPlaylist(content string, baseURL *url.URL) (*MasterPlaylist, error) {
|
||||||
|
if err := validateM3U8Header(content); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
master := &MasterPlaylist{Rendition: make(map[string][]*MediaRendition)}
|
||||||
|
var pending *Variant
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "#EXT-X-STREAM-INF:") {
|
||||||
|
attrs := parseAttributes(line[len("#EXT-X-STREAM-INF:"):])
|
||||||
|
pending = &Variant{
|
||||||
|
Bandwidth: parseInt64(attrs["BANDWIDTH"]),
|
||||||
|
AvgBandwidth: parseInt64(attrs["AVERAGE-BANDWIDTH"]),
|
||||||
|
Resolution: attrs["RESOLUTION"],
|
||||||
|
Codecs: unquote(attrs["CODECS"]),
|
||||||
|
Name: unquote(attrs["NAME"]),
|
||||||
|
AudioGroup: unquote(attrs["AUDIO"]),
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "#EXT-X-MEDIA:") {
|
||||||
|
attrs := parseAttributes(line[len("#EXT-X-MEDIA:"):])
|
||||||
|
rendition := &MediaRendition{
|
||||||
|
Type: strings.ToUpper(attrs["TYPE"]),
|
||||||
|
GroupID: unquote(attrs["GROUP-ID"]),
|
||||||
|
}
|
||||||
|
// Only an explicit URI means a separate track; resolving an empty
|
||||||
|
// reference would fabricate one out of the playlist URL.
|
||||||
|
if uri := unquote(attrs["URI"]); uri != "" {
|
||||||
|
rendition.URI = resolveURL(baseURL, uri)
|
||||||
|
}
|
||||||
|
if rendition.GroupID != "" {
|
||||||
|
// Keep every member: a group is unsupported as a whole when
|
||||||
|
// ANY of its members carries its own URI.
|
||||||
|
master.Rendition[rendition.GroupID] = append(master.Rendition[rendition.GroupID], rendition)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pending != nil {
|
||||||
|
if line[0] != '#' {
|
||||||
|
pending.URI = resolveURL(baseURL, line)
|
||||||
|
master.Variants = append(master.Variants, pending)
|
||||||
|
}
|
||||||
|
pending = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(master.Variants) == 0 {
|
||||||
|
return nil, errors.New("master playlist has no variants")
|
||||||
|
}
|
||||||
|
return master, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAudioRendition rejects the selected variant when its AUDIO group
|
||||||
|
// carries a separate audio track: downloading only the video variant would
|
||||||
|
// silently produce a silent file. Groups the variant does not reference are
|
||||||
|
// ignored (variants without an AUDIO attribute are assumed self-contained).
|
||||||
|
func checkAudioRendition(master *MasterPlaylist, best *Variant) error {
|
||||||
|
if best == nil || best.AudioGroup == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, rendition := range master.Rendition[best.AudioGroup] {
|
||||||
|
if rendition == nil || rendition.Type != "AUDIO" || rendition.URI == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"selected variant uses a separate audio rendition (group %q), which is not supported: the merged output would have no audio",
|
||||||
|
best.AudioGroup)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickBestVariant selects the highest-bandwidth variant.
|
||||||
|
func PickBestVariant(variants []*Variant) *Variant {
|
||||||
|
var best *Variant
|
||||||
|
for _, v := range variants {
|
||||||
|
if best == nil || v.Bandwidth > best.Bandwidth {
|
||||||
|
best = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMedia parses a media playlist into an ordered segment plan.
|
||||||
|
func ParseMedia(content string, baseURL *url.URL) (*Media, error) {
|
||||||
|
if err := validateM3U8Header(content); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
media := &Media{Live: true}
|
||||||
|
var currentKey *Key
|
||||||
|
var pendingByterange *ByteRange
|
||||||
|
var pendingGap bool
|
||||||
|
var pendingDuration float64
|
||||||
|
var lastRangeEnd int64
|
||||||
|
var sequence int64
|
||||||
|
var mapIdentity string // normalized identity of the current EXT-X-MAP
|
||||||
|
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line[0] != '#' {
|
||||||
|
// Every non-comment line in a media playlist is a segment URI.
|
||||||
|
seg := &Segment{
|
||||||
|
URI: resolveURL(baseURL, line),
|
||||||
|
Sequence: sequence,
|
||||||
|
Byterange: pendingByterange,
|
||||||
|
Key: currentKey,
|
||||||
|
Gap: pendingGap,
|
||||||
|
Duration: pendingDuration,
|
||||||
|
}
|
||||||
|
if pendingByterange != nil {
|
||||||
|
// Byterange segments have exact sizes straight from the playlist.
|
||||||
|
seg.Size = pendingByterange.Length
|
||||||
|
}
|
||||||
|
sequence++
|
||||||
|
pendingByterange = nil
|
||||||
|
pendingGap = false
|
||||||
|
pendingDuration = 0
|
||||||
|
media.Segments = append(media.Segments, seg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "#EXTINF:"):
|
||||||
|
value := line[len("#EXTINF:"):]
|
||||||
|
if idx := strings.Index(value, ","); idx >= 0 {
|
||||||
|
value = value[:idx]
|
||||||
|
}
|
||||||
|
pendingDuration = parseFloat(value)
|
||||||
|
case strings.HasPrefix(line, "#EXT-X-KEY:"):
|
||||||
|
key, err := parseKey(line[len("#EXT-X-KEY:"):], baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
currentKey = key
|
||||||
|
case strings.HasPrefix(line, "#EXT-X-MAP:"):
|
||||||
|
attrs := parseAttributes(line[len("#EXT-X-MAP:"):])
|
||||||
|
uriAttr := unquote(attrs["URI"])
|
||||||
|
if uriAttr == "" {
|
||||||
|
return nil, errors.New("EXT-X-MAP without URI is not supported")
|
||||||
|
}
|
||||||
|
resolved := resolveURL(baseURL, uriAttr)
|
||||||
|
// The init section identity covers every attribute, not just the
|
||||||
|
// URI: a repeated tag with a different BYTERANGE addresses
|
||||||
|
// different bytes and changes the init section. NUL keeps the
|
||||||
|
// two parts unambiguously separated.
|
||||||
|
identity := resolved + "\x00" + attrs["BYTERANGE"]
|
||||||
|
if mapIdentity != "" {
|
||||||
|
if mapIdentity != identity {
|
||||||
|
return nil, errors.New("changing init sections are not supported")
|
||||||
|
}
|
||||||
|
media.IsFMP4 = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mapIdentity = identity
|
||||||
|
init := &Segment{
|
||||||
|
URI: resolved,
|
||||||
|
Sequence: sequence,
|
||||||
|
IsInit: true,
|
||||||
|
Key: currentKey,
|
||||||
|
}
|
||||||
|
if attrs["BYTERANGE"] != "" {
|
||||||
|
init.Byterange = parseByteRange(attrs["BYTERANGE"], 0)
|
||||||
|
if init.Byterange == nil {
|
||||||
|
return nil, fmt.Errorf("invalid EXT-X-MAP BYTERANGE: %s", attrs["BYTERANGE"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if init.Key != nil && init.Key.IV == nil {
|
||||||
|
// RFC 8216 only defines sequence-derived IVs for media
|
||||||
|
// segments; guessing one for the init section produces
|
||||||
|
// corrupt output, so require the playlist to be explicit.
|
||||||
|
return nil, errors.New("encrypted EXT-X-MAP without an explicit IV is not supported")
|
||||||
|
}
|
||||||
|
// The init section is prepended to the segment plan, it takes
|
||||||
|
// the media sequence number of the playlist for IV derivation.
|
||||||
|
media.Segments = append(media.Segments, init)
|
||||||
|
media.IsFMP4 = true
|
||||||
|
case strings.HasPrefix(line, "#EXT-X-BYTERANGE:"):
|
||||||
|
pendingByterange = parseByteRange(line[len("#EXT-X-BYTERANGE:"):], lastRangeEnd)
|
||||||
|
if pendingByterange == nil {
|
||||||
|
return nil, fmt.Errorf("invalid EXT-X-BYTERANGE: %s", line)
|
||||||
|
}
|
||||||
|
case line == "#EXT-X-GAP":
|
||||||
|
pendingGap = true
|
||||||
|
case line == "#EXT-X-DISCONTINUITY":
|
||||||
|
media.Discontinuity = true
|
||||||
|
case line == "#EXT-X-ENDLIST":
|
||||||
|
media.Live = false
|
||||||
|
case strings.HasPrefix(line, "#EXT-X-MEDIA-SEQUENCE:"):
|
||||||
|
sequence = parseInt64(line[len("#EXT-X-MEDIA-SEQUENCE:"):])
|
||||||
|
case strings.HasPrefix(line, "#EXT-X-VERSION:"):
|
||||||
|
media.Version = int(parseInt64(line[len("#EXT-X-VERSION:"):]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if pendingByterange != nil {
|
||||||
|
// Non-segment tags between BYTERANGE and its URI must not reset the
|
||||||
|
// running offset, so it is tracked here after each parse.
|
||||||
|
lastRangeEnd = pendingByterange.Offset + pendingByterange.Length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(media.Segments) == 0 {
|
||||||
|
return nil, errors.New("media playlist has no segments")
|
||||||
|
}
|
||||||
|
return media, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSupported rejects playlist features the byte-concatenation merge
|
||||||
|
// cannot reproduce faithfully. Skipping such features silently would report
|
||||||
|
// success while producing a broken file.
|
||||||
|
func validateSupported(media *Media) error {
|
||||||
|
if media.Live {
|
||||||
|
return errors.New("live streams are not supported yet")
|
||||||
|
}
|
||||||
|
if media.Discontinuity {
|
||||||
|
return errors.New("EXT-X-DISCONTINUITY is not supported: the merged output would have a broken timeline")
|
||||||
|
}
|
||||||
|
for _, seg := range media.Segments {
|
||||||
|
if seg.Gap {
|
||||||
|
return errors.New("EXT-X-GAP is not supported: the missing segment would leave a hole in the merged output")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseKey(attrs string, baseURL *url.URL) (*Key, error) {
|
||||||
|
parsed := parseAttributes(attrs)
|
||||||
|
switch method := parsed["METHOD"]; method {
|
||||||
|
case keyMethodNone:
|
||||||
|
return nil, nil
|
||||||
|
case keyMethodAES128:
|
||||||
|
uri := unquote(parsed["URI"])
|
||||||
|
if uri == "" {
|
||||||
|
return nil, errors.New("AES-128 EXT-X-KEY without URI")
|
||||||
|
}
|
||||||
|
if format := unquote(parsed["KEYFORMAT"]); format != "" && format != "identity" {
|
||||||
|
return nil, fmt.Errorf("unsupported EXT-X-KEY KEYFORMAT: %s", format)
|
||||||
|
}
|
||||||
|
key := &Key{Method: keyMethodAES128, URI: resolveURL(baseURL, uri)}
|
||||||
|
if iv, ok := parsed["IV"]; ok {
|
||||||
|
value := strings.TrimPrefix(strings.ToLower(iv), "0x")
|
||||||
|
decoded, err := hex.DecodeString(value)
|
||||||
|
if err != nil || len(decoded) != 16 {
|
||||||
|
return nil, fmt.Errorf("invalid EXT-X-KEY IV: %s", iv)
|
||||||
|
}
|
||||||
|
key.IV = decoded
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
case keyMethodSampleAES:
|
||||||
|
return nil, errors.New("SAMPLE-AES encryption is not supported")
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported encryption method: %s", method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseByteRange(value string, defaultOffset int64) *ByteRange {
|
||||||
|
lengthPart := value
|
||||||
|
offsetPart := ""
|
||||||
|
if idx := strings.Index(value, "@"); idx >= 0 {
|
||||||
|
lengthPart = value[:idx]
|
||||||
|
offsetPart = value[idx+1:]
|
||||||
|
}
|
||||||
|
length := parseInt64(lengthPart)
|
||||||
|
if length <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
offset := defaultOffset
|
||||||
|
if offsetPart != "" {
|
||||||
|
offset = parseInt64(offsetPart)
|
||||||
|
if offset < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &ByteRange{Length: length, Offset: offset}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveURL resolves a playlist URI reference against the playlist URL
|
||||||
|
// following RFC 3986.
|
||||||
|
func resolveURL(baseURL *url.URL, ref string) string {
|
||||||
|
refURL, err := url.Parse(ref)
|
||||||
|
if err != nil {
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
return baseURL.ResolveReference(refURL).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAttributes parses an HLS attribute list like FOO="bar",NUM=42.
|
||||||
|
func parseAttributes(s string) map[string]string {
|
||||||
|
attrs := make(map[string]string)
|
||||||
|
var key, val strings.Builder
|
||||||
|
inKey := true
|
||||||
|
inQuote := false
|
||||||
|
flush := func() {
|
||||||
|
name := strings.ToUpper(strings.TrimSpace(key.String()))
|
||||||
|
if name != "" {
|
||||||
|
attrs[name] = strings.TrimSpace(val.String())
|
||||||
|
}
|
||||||
|
key.Reset()
|
||||||
|
val.Reset()
|
||||||
|
inKey = true
|
||||||
|
}
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
if inKey {
|
||||||
|
if c == '=' {
|
||||||
|
inKey = false
|
||||||
|
} else {
|
||||||
|
key.WriteByte(c)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '"' {
|
||||||
|
inQuote = !inQuote
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == ',' && !inQuote {
|
||||||
|
flush()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val.WriteByte(c)
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
func unquote(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if len(s) >= 2 && strings.HasPrefix(s, "\"") && strings.HasSuffix(s, "\"") {
|
||||||
|
return s[1 : len(s)-1]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt64(s string) int64 {
|
||||||
|
value, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFloat(s string) float64 {
|
||||||
|
value, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutputExtension returns the container file extension for the playlist.
|
||||||
|
func (m *Media) OutputExtension() string {
|
||||||
|
if m.IsFMP4 {
|
||||||
|
return ".mp4"
|
||||||
|
}
|
||||||
|
return ".ts"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeriveOutputName builds the merged output file name from a playlist URL.
|
||||||
|
func DeriveOutputName(playlistURL string, media *Media) string {
|
||||||
|
name := ""
|
||||||
|
if u, err := url.Parse(playlistURL); err == nil {
|
||||||
|
name = path.Base(u.Path)
|
||||||
|
name = strings.TrimSuffix(name, path.Ext(name))
|
||||||
|
}
|
||||||
|
if name == "" || name == "/" || name == "." {
|
||||||
|
if u, err := url.Parse(playlistURL); err == nil {
|
||||||
|
name = u.Hostname()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name + media.OutputExtension()
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package hls
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed testdata/olemovienews.m3u8
|
||||||
|
var realWorldPlaylist string
|
||||||
|
|
||||||
|
// TestParseMediaRealWorld guards parsing against a real site playlist
|
||||||
|
// (olemovienews.com VOD, 138 TS segments).
|
||||||
|
func TestParseMediaRealWorld(t *testing.T) {
|
||||||
|
base, err := url.Parse("https://europe.olemovienews.com/ts2/20230125/afAxfjit/mp4/afAxfjit.mp4/index-v1-a1.m3u8")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
media, err := ParseMedia(realWorldPlaylist, base)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if media.Live {
|
||||||
|
t.Error("real VOD playlist should not be live")
|
||||||
|
}
|
||||||
|
if media.IsFMP4 {
|
||||||
|
t.Error("playlist should be detected as TS")
|
||||||
|
}
|
||||||
|
if len(media.Segments) != 138 {
|
||||||
|
t.Errorf("want 138 segments, got %d", len(media.Segments))
|
||||||
|
}
|
||||||
|
first := media.Segments[0]
|
||||||
|
if first.URI != "https://europe.olemovienews.com/ts2/20230125/afAxfjit/mp4/afAxfjit.mp4/seg-1-v1-a1.ts" {
|
||||||
|
t.Errorf("segment URI resolution wrong: %s", first.URI)
|
||||||
|
}
|
||||||
|
if media.Segments[0].Sequence != 1 {
|
||||||
|
t.Errorf("first sequence = %d, want 1", media.Segments[0].Sequence)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package hls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustURL(t *testing.T, raw string) *url.URL {
|
||||||
|
t.Helper()
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMediaTS(t *testing.T) {
|
||||||
|
content := "#EXTM3U\n" +
|
||||||
|
"#EXT-X-TARGETDURATION:20\n" +
|
||||||
|
"#EXT-X-VERSION:3\n" +
|
||||||
|
"#EXT-X-MEDIA-SEQUENCE:1\n" +
|
||||||
|
"#EXTINF:20.000,\n" +
|
||||||
|
"seg-1.ts\n" +
|
||||||
|
"#EXTINF:20.000,\n" +
|
||||||
|
"seg-2.ts\n" +
|
||||||
|
"#EXT-X-ENDLIST\n"
|
||||||
|
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if media.Live {
|
||||||
|
t.Error("VOD playlist should not be live")
|
||||||
|
}
|
||||||
|
if media.IsFMP4 {
|
||||||
|
t.Error("TS playlist should not be fMP4")
|
||||||
|
}
|
||||||
|
if len(media.Segments) != 2 {
|
||||||
|
t.Fatalf("want 2 segments, got %d", len(media.Segments))
|
||||||
|
}
|
||||||
|
want := "https://cdn.example.com/vod/seg-1.ts"
|
||||||
|
if media.Segments[0].URI != want {
|
||||||
|
t.Errorf("want uri %s, got %s", want, media.Segments[0].URI)
|
||||||
|
}
|
||||||
|
if media.Segments[0].Sequence != 1 {
|
||||||
|
t.Errorf("want sequence 1, got %d", media.Segments[0].Sequence)
|
||||||
|
}
|
||||||
|
if media.Segments[0].Key != nil {
|
||||||
|
t.Error("segment should be unencrypted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMediaWithoutExtinf(t *testing.T) {
|
||||||
|
// Some playlists list segment URIs without EXTINF lines.
|
||||||
|
content := "#EXTM3U\nseg-1.ts\nseg-2.ts\n#EXT-X-ENDLIST\n"
|
||||||
|
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/a/index.m3u8"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(media.Segments) != 2 {
|
||||||
|
t.Fatalf("want 2 segments, got %d", len(media.Segments))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMediaLive(t *testing.T) {
|
||||||
|
content := "#EXTM3U\n#EXT-X-TARGETDURATION:6\n#EXTINF:6.0,\nseg-1.ts\n"
|
||||||
|
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/live/index.m3u8"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !media.Live {
|
||||||
|
t.Error("playlist without ENDLIST should be live")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMediaFMP4(t *testing.T) {
|
||||||
|
content := "#EXTM3U\n" +
|
||||||
|
"#EXT-X-VERSION:6\n" +
|
||||||
|
"#EXT-X-MAP:URI=\"init.mp4\"\n" +
|
||||||
|
"#EXTINF:5.0,\n" +
|
||||||
|
"seg-1.m4s\n" +
|
||||||
|
"#EXT-X-ENDLIST\n"
|
||||||
|
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !media.IsFMP4 {
|
||||||
|
t.Error("playlist with MAP should be fMP4")
|
||||||
|
}
|
||||||
|
if len(media.Segments) != 2 {
|
||||||
|
t.Fatalf("want 2 segments (init + 1), got %d", len(media.Segments))
|
||||||
|
}
|
||||||
|
if !media.Segments[0].IsInit {
|
||||||
|
t.Error("first segment should be the init section")
|
||||||
|
}
|
||||||
|
if media.Segments[0].URI != "https://cdn.example.com/vod/init.mp4" {
|
||||||
|
t.Errorf("init uri wrong: %s", media.Segments[0].URI)
|
||||||
|
}
|
||||||
|
if media.OutputExtension() != ".mp4" {
|
||||||
|
t.Errorf("want .mp4, got %s", media.OutputExtension())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMediaMultiMapError(t *testing.T) {
|
||||||
|
content := "#EXTM3U\n" +
|
||||||
|
"#EXT-X-MAP:URI=\"init1.mp4\"\n" +
|
||||||
|
"#EXTINF:5.0,\nseg-1.m4s\n" +
|
||||||
|
"#EXT-X-DISCONTINUITY\n" +
|
||||||
|
"#EXT-X-MAP:URI=\"init2.mp4\"\n" +
|
||||||
|
"#EXTINF:5.0,\nseg-2.m4s\n" +
|
||||||
|
"#EXT-X-ENDLIST\n"
|
||||||
|
if _, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8")); err == nil {
|
||||||
|
t.Error("multi-period fMP4 should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMediaKeyRotation(t *testing.T) {
|
||||||
|
content := "#EXTM3U\n" +
|
||||||
|
"#EXT-X-KEY:METHOD=AES-128,URI=\"key1.bin\",IV=0x00000000000000000000000000000001\n" +
|
||||||
|
"#EXTINF:5.0,\nseg-1.ts\n" +
|
||||||
|
"#EXT-X-KEY:METHOD=AES-128,URI=\"key2.bin\"\n" +
|
||||||
|
"#EXTINF:5.0,\nseg-2.ts\n" +
|
||||||
|
"#EXT-X-KEY:METHOD=NONE\n" +
|
||||||
|
"#EXTINF:5.0,\nseg-3.ts\n" +
|
||||||
|
"#EXT-X-ENDLIST\n"
|
||||||
|
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if media.Segments[0].Key == nil || media.Segments[0].Key.URI != "https://cdn.example.com/vod/key1.bin" {
|
||||||
|
t.Fatalf("segment 1 key wrong: %+v", media.Segments[0].Key)
|
||||||
|
}
|
||||||
|
if len(media.Segments[0].Key.IV) != 16 || media.Segments[0].Key.IV[15] != 1 {
|
||||||
|
t.Error("explicit IV not parsed")
|
||||||
|
}
|
||||||
|
if media.Segments[1].Key == nil || media.Segments[1].Key.URI != "https://cdn.example.com/vod/key2.bin" {
|
||||||
|
t.Fatalf("segment 2 key wrong: %+v", media.Segments[1].Key)
|
||||||
|
}
|
||||||
|
if media.Segments[1].Key.IV != nil {
|
||||||
|
t.Error("key without IV should leave IV nil (sequence-derived)")
|
||||||
|
}
|
||||||
|
if media.Segments[2].Key != nil {
|
||||||
|
t.Error("segment 3 should be unencrypted after METHOD=NONE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMediaSampleAESError(t *testing.T) {
|
||||||
|
content := "#EXTM3U\n#EXT-X-KEY:METHOD=SAMPLE-AES,URI=\"skd://key\"\n#EXTINF:5.0,\nseg-1.ts\n"
|
||||||
|
if _, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8")); err == nil {
|
||||||
|
t.Error("SAMPLE-AES should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMediaByterange(t *testing.T) {
|
||||||
|
content := "#EXTM3U\n" +
|
||||||
|
"#EXT-X-BYTERANGE:1000@0\nseg.ts\n" +
|
||||||
|
"#EXT-X-BYTERANGE:2000\nseg.ts\n" +
|
||||||
|
"#EXT-X-BYTERANGE:3000\nseg.ts\n" +
|
||||||
|
"#EXT-X-ENDLIST\n"
|
||||||
|
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ranges := [][2]int64{{0, 1000}, {1000, 2000}, {3000, 3000}}
|
||||||
|
for i, want := range ranges {
|
||||||
|
br := media.Segments[i].Byterange
|
||||||
|
if br == nil {
|
||||||
|
t.Fatalf("segment %d byterange missing", i)
|
||||||
|
}
|
||||||
|
if br.Offset != want[0] || br.Length != want[1] {
|
||||||
|
t.Errorf("segment %d: want offset %d length %d, got %d/%d", i, want[0], want[1], br.Offset, br.Length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMediaGap(t *testing.T) {
|
||||||
|
content := "#EXTM3U\n#EXTINF:5.0,\nseg-1.ts\n#EXT-X-GAP\n#EXTINF:5.0,\nseg-2.ts\n#EXT-X-ENDLIST\n"
|
||||||
|
media, err := ParseMedia(content, mustURL(t, "https://cdn.example.com/vod/index.m3u8"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if media.Segments[0].Gap || !media.Segments[1].Gap {
|
||||||
|
t.Error("gap flags wrong")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMediaInvalidHeader(t *testing.T) {
|
||||||
|
if _, err := ParseMedia("not a playlist", mustURL(t, "https://cdn.example.com/a.m3u8")); err == nil {
|
||||||
|
t.Error("invalid content should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseMasterPickBest(t *testing.T) {
|
||||||
|
content := "#EXTM3U\n" +
|
||||||
|
"#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",DEFAULT=YES,URI=\"audio/index.m3u8\"\n" +
|
||||||
|
"#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360\n" +
|
||||||
|
"360p/index.m3u8\n" +
|
||||||
|
"#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=1920x1080,CODECS=\"avc1.640028,mp4a.40.2\"\n" +
|
||||||
|
"1080p/index.m3u8?sign=abc\n" +
|
||||||
|
"#EXT-X-ENDLIST\n"
|
||||||
|
variants, err := ParseMaster(content, mustURL(t, "https://cdn.example.com/vod/master.m3u8"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(variants) != 2 {
|
||||||
|
t.Fatalf("want 2 variants, got %d", len(variants))
|
||||||
|
}
|
||||||
|
best := PickBestVariant(variants)
|
||||||
|
if best.Bandwidth != 2500000 {
|
||||||
|
t.Errorf("want highest bandwidth variant, got %d", best.Bandwidth)
|
||||||
|
}
|
||||||
|
if best.URI != "https://cdn.example.com/vod/1080p/index.m3u8?sign=abc" {
|
||||||
|
t.Errorf("variant uri wrong: %s", best.URI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveURLQueryNotInherited(t *testing.T) {
|
||||||
|
base := mustURL(t, "https://cdn.example.com/vod/index.m3u8?sign=abc")
|
||||||
|
got := resolveURL(base, "seg-1.ts")
|
||||||
|
if got != "https://cdn.example.com/vod/seg-1.ts" {
|
||||||
|
t.Errorf("relative resolution should follow RFC3986 (no query inheritance), got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeriveOutputName(t *testing.T) {
|
||||||
|
media := &Media{}
|
||||||
|
if got := DeriveOutputName("https://cdn.example.com/ts2/a/index-v1-a1.m3u8", media); got != "index-v1-a1.ts" {
|
||||||
|
t.Errorf("want index-v1-a1.ts, got %s", got)
|
||||||
|
}
|
||||||
|
fmp4 := &Media{IsFMP4: true}
|
||||||
|
if got := DeriveOutputName("https://cdn.example.com/ts2/a/index.m3u8", fmp4); got != "index.mp4" {
|
||||||
|
t.Errorf("want index.mp4, got %s", got)
|
||||||
|
}
|
||||||
|
if got := DeriveOutputName("https://cdn.example.com/play?token=x", media); got != "play.ts" {
|
||||||
|
t.Errorf("want play.ts, got %s", got)
|
||||||
|
}
|
||||||
|
if got := DeriveOutputName("https://cdn.example.com/", media); got != "cdn.example.com.ts" {
|
||||||
|
t.Errorf("want host fallback, got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAttributesQuotedComma(t *testing.T) {
|
||||||
|
attrs := parseAttributes(`URI="a,b.ts",IV=0xABCDEF00000000000000000000000001,NAME="x"`)
|
||||||
|
if attrs["URI"] != "a,b.ts" {
|
||||||
|
t.Errorf("quoted comma value wrong: %q", attrs["URI"])
|
||||||
|
}
|
||||||
|
if attrs["IV"] != "0xABCDEF00000000000000000000000001" {
|
||||||
|
t.Errorf("hex value wrong: %q", attrs["IV"])
|
||||||
|
}
|
||||||
|
if attrs["NAME"] != "x" {
|
||||||
|
t.Errorf("quoted value wrong: %q", attrs["NAME"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
package hls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetcherE2E_SameURLTwoTasks(t *testing.T) {
|
||||||
|
const total = 6
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
var playlist strings.Builder
|
||||||
|
playlist.WriteString("#EXTM3U\n")
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
playlist.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i))
|
||||||
|
}
|
||||||
|
playlist.WriteString("#EXT-X-ENDLIST\n")
|
||||||
|
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(playlist.String()))
|
||||||
|
})
|
||||||
|
segments := make(map[int][]byte)
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
segments[i] = []byte(fmt.Sprintf("SHARED-SEGMENT-%02d", i))
|
||||||
|
idx := i
|
||||||
|
mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", idx), func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(120 * time.Millisecond)
|
||||||
|
w.Write(segments[idx])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(mux)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := testConfig()
|
||||||
|
cfg.SegmentConnections = 2
|
||||||
|
cfg.PrefetchContentLength = false
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Two tasks for the same playlist URL into the same directory, like a
|
||||||
|
// user duplicating a task. The engine would rename the second output
|
||||||
|
// (AutoRename); mirror that here.
|
||||||
|
f1 := newTestFetcher(t, cfg)
|
||||||
|
if err := f1.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: dir}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f2 := newTestFetcher(t, cfg)
|
||||||
|
if err := f2.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: dir, Name: "video-2.ts"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if f1.state.StagingDir == "" || f1.state.StagingDir == f2.state.StagingDir {
|
||||||
|
t.Fatalf("staging dirs must be distinct per task: %q vs %q", f1.state.StagingDir, f2.state.StagingDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start task 1, let it stage some segments, then pause it.
|
||||||
|
if err := f1.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
if err := f1.Pause(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
staged1, err := os.ReadDir(f1.state.StagingDir)
|
||||||
|
if err != nil || len(staged1) == 0 {
|
||||||
|
t.Fatalf("task 1 should have staged segments, dir: %s", f1.state.StagingDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 2 must complete without touching task 1's staging data...
|
||||||
|
if err := f2.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f2.Wait(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stillThere, err := os.ReadDir(f1.state.StagingDir)
|
||||||
|
if err != nil || len(stillThere) != len(staged1) {
|
||||||
|
t.Fatalf("task 2 disturbed task 1 staging: had %d entries, now %d", len(staged1), len(stillThere))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...and task 1 resumes to a complete, correct output of its own.
|
||||||
|
if err := f1.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f1.Wait(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var want []byte
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
want = append(want, segments[i]...)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(readOutput(t, f1), want) || !bytes.Equal(readOutput(t, f2), want) {
|
||||||
|
t.Error("same-URL task outputs mismatch")
|
||||||
|
}
|
||||||
|
entries, _ := os.ReadDir(dir)
|
||||||
|
if len(entries) != 2 {
|
||||||
|
names := make([]string, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
t.Errorf("expected only the two outputs to remain, got %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetcherE2E_ForeignJournalDiscarded(t *testing.T) {
|
||||||
|
const total = 3
|
||||||
|
var served atomic.Int64
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
var playlist strings.Builder
|
||||||
|
playlist.WriteString("#EXTM3U\n")
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
playlist.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i))
|
||||||
|
}
|
||||||
|
playlist.WriteString("#EXT-X-ENDLIST\n")
|
||||||
|
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(playlist.String()))
|
||||||
|
})
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", i), func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
served.Add(1)
|
||||||
|
w.Write([]byte("DATA"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(mux)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := testConfig()
|
||||||
|
cfg.PrefetchContentLength = false
|
||||||
|
f := newTestFetcher(t, cfg)
|
||||||
|
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Plant a journal that does not belong to this plan (wrong fingerprint).
|
||||||
|
tempDir := f.resolveStagingDirLocked()
|
||||||
|
if err := os.MkdirAll(tempDir, 0777); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
foreign := journalFile{
|
||||||
|
StagingID: "some-other-task",
|
||||||
|
PlanHash: "deadbeef",
|
||||||
|
SegmentCount: total,
|
||||||
|
Sizes: map[string]int64{"0": 4, "1": 4, "2": 4},
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(foreign)
|
||||||
|
if err := os.WriteFile(filepath.Join(tempDir, "journal.json"), data, 0666); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.Wait(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := served.Load(); got != total {
|
||||||
|
t.Errorf("foreign journal must be discarded, want %d downloads, got %d", total, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetcherE2E_MergeFailurePreservesStaging(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("#EXTM3U\n#EXTINF:4,\na.ts\n#EXTINF:4,\nb.ts\n#EXT-X-ENDLIST\n"))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/a.ts", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("AAAA")) })
|
||||||
|
mux.HandleFunc("/b.ts", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("BBBB")) })
|
||||||
|
server := httptest.NewServer(mux)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
f := newTestFetcher(t, testConfig())
|
||||||
|
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
run := &hlsRun{
|
||||||
|
ctx: context.Background(),
|
||||||
|
cancel: func() {},
|
||||||
|
done: make(chan struct{}),
|
||||||
|
segs: f.state.Segments,
|
||||||
|
outputPath: f.meta.SingleFilepath(),
|
||||||
|
stagingID: f.state.StagingID,
|
||||||
|
planHash: planFingerprint(f.state),
|
||||||
|
tempDir: f.resolveStagingDirLocked(),
|
||||||
|
keyCache: make(map[string][]byte),
|
||||||
|
journal: make(map[int64]int64),
|
||||||
|
}
|
||||||
|
run.journalPath = filepath.Join(run.tempDir, "journal.json")
|
||||||
|
if err := os.MkdirAll(run.tempDir, 0777); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(segmentPath(run.tempDir, 0), []byte("AAAA"), 0666); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Segment 1 is missing on disk: the merge must fail without deleting the
|
||||||
|
// staged data or leaving a partial output behind.
|
||||||
|
if err := f.merge(run); err == nil {
|
||||||
|
t.Fatal("merge with a missing segment should fail")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(segmentPath(run.tempDir, 0)); err != nil {
|
||||||
|
t.Fatal("merge failure must preserve staged segments")
|
||||||
|
}
|
||||||
|
entries, _ := os.ReadDir(filepath.Dir(run.outputPath))
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.HasSuffix(e.Name(), ".part") {
|
||||||
|
t.Errorf("failed merge left a partial output behind: %s", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recover the segment: the retried merge succeeds.
|
||||||
|
if err := os.WriteFile(segmentPath(run.tempDir, 1), []byte("BBBB"), 0666); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.merge(run); err != nil {
|
||||||
|
t.Fatalf("retried merge failed: %v", err)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(run.outputPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(got) != "AAAABBBB" {
|
||||||
|
t.Errorf("retried merge output mismatch: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetcherE2E_ReMergeOverExistingOutput(t *testing.T) {
|
||||||
|
const total = 2
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
var playlist strings.Builder
|
||||||
|
playlist.WriteString("#EXTM3U\n")
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
playlist.WriteString(fmt.Sprintf("#EXTINF:4,\nseg-%d.ts\n", i))
|
||||||
|
}
|
||||||
|
playlist.WriteString("#EXT-X-ENDLIST\n")
|
||||||
|
mux.HandleFunc("/video.m3u8", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(playlist.String()))
|
||||||
|
})
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
mux.HandleFunc(fmt.Sprintf("/seg-%d.ts", i), func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("DATA"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(mux)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
f := newTestFetcher(t, testConfig())
|
||||||
|
if err := f.Resolve(&base.Request{URL: server.URL + "/video.m3u8"}, &base.Options{Path: t.TempDir()}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tempDir := f.resolveStagingDirLocked()
|
||||||
|
if err := os.MkdirAll(tempDir, 0777); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Simulate the crash window after a completed rename but before the task
|
||||||
|
// was marked done: a stale output file, a complete journal and all
|
||||||
|
// segments staged.
|
||||||
|
stale := filepath.Join(filepath.Dir(f.meta.SingleFilepath()), f.state.OutputName)
|
||||||
|
if err := os.WriteFile(stale, []byte("STALE-OUTPUT"), 0666); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
jr := &hlsRun{
|
||||||
|
segs: f.state.Segments,
|
||||||
|
stagingID: f.state.StagingID,
|
||||||
|
planHash: planFingerprint(f.state),
|
||||||
|
journalPath: filepath.Join(tempDir, "journal.json"),
|
||||||
|
journal: map[int64]int64{0: 4, 1: 4},
|
||||||
|
}
|
||||||
|
if err := saveJournal(jr); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
if err := os.WriteFile(segmentPath(tempDir, int64(i)), []byte("DATA"), 0666); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.Wait(); err != nil {
|
||||||
|
t.Fatalf("re-merge over an existing output failed: %v", err)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(stale)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(got) != "DATADATA" {
|
||||||
|
t.Errorf("stale output was not replaced: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package hls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mustParseURL(t *testing.T, raw string) *url.URL {
|
||||||
|
t.Helper()
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// subsetCase is a media playlist plus the error substring the pipeline must
|
||||||
|
// reject it with; wantOK playlists must parse AND validate cleanly.
|
||||||
|
type subsetCase struct {
|
||||||
|
name string
|
||||||
|
playlist string
|
||||||
|
wantErr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubsetRejections(t *testing.T) {
|
||||||
|
cases := []subsetCase{
|
||||||
|
{
|
||||||
|
name: "gap",
|
||||||
|
playlist: "#EXTM3U\n#EXTINF:4,\na.ts\n#EXT-X-GAP\n#EXTINF:4,\nb.ts\n#EXT-X-ENDLIST\n",
|
||||||
|
wantErr: "EXT-X-GAP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gap-before-first-segment",
|
||||||
|
playlist: "#EXTM3U\n#EXT-X-GAP\n#EXTINF:4,\na.ts\n#EXT-X-ENDLIST\n",
|
||||||
|
wantErr: "EXT-X-GAP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "discontinuity",
|
||||||
|
playlist: "#EXTM3U\n#EXTINF:4,\na.ts\n#EXT-X-DISCONTINUITY\n#EXTINF:4,\nb.ts\n#EXT-X-ENDLIST\n",
|
||||||
|
wantErr: "EXT-X-DISCONTINUITY",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "live",
|
||||||
|
playlist: "#EXTM3U\n#EXTINF:4,\na.ts\n",
|
||||||
|
wantErr: "live",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "keyformat",
|
||||||
|
playlist: "#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI=\"k\",KEYFORMAT=\"com.apple.streamingkeydelivery\"\n#EXTINF:4,\na.ts\n#EXT-X-ENDLIST\n",
|
||||||
|
wantErr: "KEYFORMAT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "encrypted-map-without-iv",
|
||||||
|
playlist: "#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI=\"k\"\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXTINF:4,\na.m4s\n#EXT-X-ENDLIST\n",
|
||||||
|
wantErr: "explicit IV",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "map-change-byterange",
|
||||||
|
playlist: "#EXTM3U\n#EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"100@0\"\n#EXTINF:4,\na.m4s\n#EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"100@100\"\n#EXTINF:4,\nb.m4s\n#EXT-X-ENDLIST\n",
|
||||||
|
wantErr: "changing init sections",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "map-change-uri",
|
||||||
|
playlist: "#EXTM3U\n#EXT-X-MAP:URI=\"init1.mp4\"\n#EXTINF:4,\na.m4s\n#EXT-X-MAP:URI=\"init2.mp4\"\n#EXTINF:4,\nb.m4s\n#EXT-X-ENDLIST\n",
|
||||||
|
wantErr: "changing init sections",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
baseURL := mustParseURL(t, "https://cdn.example.com/vod/index.m3u8")
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
media, parseErr := ParseMedia(tc.playlist, baseURL)
|
||||||
|
if parseErr != nil {
|
||||||
|
if !strings.Contains(parseErr.Error(), tc.wantErr) {
|
||||||
|
t.Fatalf("parse error %q does not mention %q", parseErr, tc.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := validateSupported(media)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("playlist should be rejected with %q", tc.wantErr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||||
|
t.Fatalf("error %q does not mention %q", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubsetRepeatedSameMapAllowed(t *testing.T) {
|
||||||
|
playlist := "#EXTM3U\n" +
|
||||||
|
"#EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"100@0\"\n" +
|
||||||
|
"#EXTINF:4,\na.m4s\n" +
|
||||||
|
"#EXT-X-MAP:URI=\"init.mp4\",BYTERANGE=\"100@0\"\n" +
|
||||||
|
"#EXTINF:4,\nb.m4s\n" +
|
||||||
|
"#EXT-X-ENDLIST\n"
|
||||||
|
media, err := ParseMedia(playlist, mustParseURL(t, "https://cdn.example.com/vod/index.m3u8"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := validateSupported(media); err != nil {
|
||||||
|
t.Fatalf("identical repeated MAP must be allowed: %v", err)
|
||||||
|
}
|
||||||
|
if len(media.Segments) != 3 { // init + 2 segments, no duplicate init
|
||||||
|
t.Fatalf("want 3 segments (init + 2), got %d", len(media.Segments))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMasterAudioRenditionDetection(t *testing.T) {
|
||||||
|
masterURL := mustParseURL(t, "https://cdn.example.com/master.m3u8")
|
||||||
|
|
||||||
|
separateAudio := "#EXTM3U\n" +
|
||||||
|
"#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",DEFAULT=YES,URI=\"audio/index.m3u8\"\n" +
|
||||||
|
"#EXT-X-STREAM-INF:BANDWIDTH=1000000,AUDIO=\"aud\"\nvideo/index.m3u8\n"
|
||||||
|
master, err := ParseMasterPlaylist(separateAudio, masterURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := checkAudioRendition(master, PickBestVariant(master.Variants)); err == nil {
|
||||||
|
t.Fatal("selected variant with a separate audio rendition must be rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A group the variant does not reference must not cause a false positive.
|
||||||
|
unrelatedGroup := "#EXTM3U\n" +
|
||||||
|
"#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",DEFAULT=YES,URI=\"audio/index.m3u8\"\n" +
|
||||||
|
"#EXT-X-STREAM-INF:BANDWIDTH=1000000\nvideo/index.m3u8\n"
|
||||||
|
master, err = ParseMasterPlaylist(unrelatedGroup, masterURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := checkAudioRendition(master, PickBestVariant(master.Variants)); err != nil {
|
||||||
|
t.Fatalf("unreferenced audio group must not be rejected: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rendition group without URIs (muxed tracks) is fine.
|
||||||
|
groupWithoutURI := "#EXTM3U\n" +
|
||||||
|
"#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=\"aud\",NAME=\"en\",DEFAULT=YES\n" +
|
||||||
|
"#EXT-X-STREAM-INF:BANDWIDTH=1000000,AUDIO=\"aud\"\nvideo/index.m3u8\n"
|
||||||
|
master, err = ParseMasterPlaylist(groupWithoutURI, masterURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := checkAudioRendition(master, PickBestVariant(master.Variants)); err != nil {
|
||||||
|
t.Fatalf("audio group without URI must not be rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
Media fixtures generated locally with FFmpeg 7.0.2 (testsrc + sine sources,
|
||||||
|
no third-party content):
|
||||||
|
|
||||||
|
- sample-00.ts / sample-01.ts: one continuous MPEG-2 TS encode split at a
|
||||||
|
packet boundary, so continuity counters and PCR are consistent across the
|
||||||
|
segment join exactly like segments cut from a single real-time stream.
|
||||||
|
- fmp4-init.mp4: ftyp+moov init section of a fragmented MP4.
|
||||||
|
- fmp4-seg1.m4s .. fmp4-seg3.m4s: moof+mdat media fragments (styp omitted by
|
||||||
|
the muxer, which is legal) split at moof boundaries from one fragmented MP4
|
||||||
|
(-movflags +frag_keyframe+empty_moov+default_base_moof -frag_duration 500000).
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+283
@@ -0,0 +1,283 @@
|
|||||||
|
#EXTM3U
|
||||||
|
#EXT-X-TARGETDURATION:20
|
||||||
|
#EXT-X-ALLOW-CACHE:YES
|
||||||
|
#EXT-X-PLAYLIST-TYPE:VOD
|
||||||
|
#EXT-X-VERSION:3
|
||||||
|
#EXT-X-MEDIA-SEQUENCE:1
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-1-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-2-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-3-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-4-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-5-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-6-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-7-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-8-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-9-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-10-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-11-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-12-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-13-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-14-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-15-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-16-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-17-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-18-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-19-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-20-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-21-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-22-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-23-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-24-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-25-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-26-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-27-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-28-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-29-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-30-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-31-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-32-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-33-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-34-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-35-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-36-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-37-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-38-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-39-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-40-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-41-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-42-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-43-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-44-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-45-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-46-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-47-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-48-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-49-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-50-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-51-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-52-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-53-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-54-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-55-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-56-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-57-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-58-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-59-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-60-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-61-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-62-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-63-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-64-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-65-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-66-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-67-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-68-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-69-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-70-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-71-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-72-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-73-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-74-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-75-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-76-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-77-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-78-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-79-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-80-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-81-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-82-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-83-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-84-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-85-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-86-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-87-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-88-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-89-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-90-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-91-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-92-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-93-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-94-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-95-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-96-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-97-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-98-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-99-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-100-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-101-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-102-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-103-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-104-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-105-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-106-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-107-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-108-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-109-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-110-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-111-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-112-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-113-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-114-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-115-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-116-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-117-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-118-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-119-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-120-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-121-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-122-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-123-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-124-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-125-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-126-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-127-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-128-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-129-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-130-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-131-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-132-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-133-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-134-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-135-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-136-v1-a1.ts
|
||||||
|
#EXTINF:20.000,
|
||||||
|
seg-137-v1-a1.ts
|
||||||
|
#EXTINF:4.111,
|
||||||
|
seg-138-v1-a1.ts
|
||||||
|
#EXT-X-ENDLIST
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
|
// DefaultUserAgent is shared by HTTP downloads and extension HTTP requests.
|
||||||
|
const DefaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
UserAgent string `json:"userAgent"`
|
UserAgent string `json:"userAgent"`
|
||||||
Connections int `json:"connections"`
|
Connections int `json:"connections"`
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ type slowStartController struct {
|
|||||||
totalLaunched int
|
totalLaunched int
|
||||||
batchPending int // Connections in current batch waiting for HTTP response
|
batchPending int // Connections in current batch waiting for HTTP response
|
||||||
batchReady int // Connections in current batch that succeeded
|
batchReady int // Connections in current batch that succeeded
|
||||||
nextBatchSize int // Next batch size: 1, 2, 4, 8...
|
nextBatchSize int // Next batch size: 1, 2, 4, 16, 256... (capped)
|
||||||
expansionCh chan struct{} // Signal to trigger next expansion
|
expansionCh chan struct{} // Signal to trigger next expansion
|
||||||
paused bool // Pause expansion (e.g., on 429)
|
paused bool // Pause expansion (e.g., on 429)
|
||||||
}
|
}
|
||||||
@@ -229,7 +229,15 @@ func (s *slowStartController) commitBatch(count int) {
|
|||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
s.totalLaunched += count
|
s.totalLaunched += count
|
||||||
s.nextBatchSize = s.nextBatchSize * 2 // Exponential growth: 1, 2, 4, 8...
|
if s.nextBatchSize == 1 {
|
||||||
|
// Bootstrap growth: squaring one would never increase the batch size.
|
||||||
|
s.nextBatchSize = min(2, s.maxConnections)
|
||||||
|
} else if s.nextBatchSize > s.maxConnections/s.nextBatchSize {
|
||||||
|
// Cap before multiplying to avoid integer overflow.
|
||||||
|
s.nextBatchSize = s.maxConnections
|
||||||
|
} else {
|
||||||
|
s.nextBatchSize *= s.nextBatchSize
|
||||||
|
}
|
||||||
s.batchPending = count
|
s.batchPending = count
|
||||||
s.batchReady = 0
|
s.batchReady = 0
|
||||||
}
|
}
|
||||||
@@ -363,20 +371,9 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
|||||||
f.meta.Opts = &base.Options{}
|
f.meta.Opts = &base.Options{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse options
|
if err := f.initOptions(); err != nil {
|
||||||
if err := base.ParseOptExtra[fhttp.OptsExtra](opts); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if opts.Extra == nil {
|
|
||||||
opts.Extra = &fhttp.OptsExtra{}
|
|
||||||
}
|
|
||||||
extra := opts.Extra.(*fhttp.OptsExtra)
|
|
||||||
if extra.Connections <= 0 {
|
|
||||||
extra.Connections = f.config.Connections
|
|
||||||
if extra.Connections <= 0 {
|
|
||||||
extra.Connections = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
f.setState(stateResolving)
|
f.setState(stateResolving)
|
||||||
|
|
||||||
@@ -512,17 +509,9 @@ func (f *Fetcher) asyncPrefetch() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create temporary file for prefetch data
|
|
||||||
tmpFile, err := os.CreateTemp("", "gopeed-prefetch-*")
|
|
||||||
if err != nil {
|
|
||||||
f.prefetchErr = err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
f.prefetchFile = tmpFile
|
|
||||||
f.prefetchFilePath = tmpFile.Name()
|
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
// Close response body when prefetch stops
|
// Close the response even if temporary-file setup fails or shutdown
|
||||||
|
// rejects a late prefetch. Otherwise the server can remain blocked writing.
|
||||||
f.resolveRespLock.Lock()
|
f.resolveRespLock.Lock()
|
||||||
if f.resolveResp != nil {
|
if f.resolveResp != nil {
|
||||||
f.resolveResp.Body.Close()
|
f.resolveResp.Body.Close()
|
||||||
@@ -531,8 +520,33 @@ func (f *Fetcher) asyncPrefetch() {
|
|||||||
f.resolveRespLock.Unlock()
|
f.resolveRespLock.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Create temporary file for prefetch data
|
||||||
|
tempDir := ""
|
||||||
|
if f.ctl != nil && f.ctl.TempDir != "" {
|
||||||
|
tempDir = f.ctl.TempDir
|
||||||
|
if err := os.MkdirAll(tempDir, 0700); err != nil {
|
||||||
|
f.prefetchErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tmpFile, err := os.CreateTemp(tempDir, "gopeed-prefetch-*")
|
||||||
|
if err != nil {
|
||||||
|
f.prefetchErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if f.ctl != nil {
|
||||||
|
if err := f.ctl.TempFiles.Track(tmpFile.Name()); err != nil {
|
||||||
|
tmpFile.Close()
|
||||||
|
os.Remove(tmpFile.Name())
|
||||||
|
f.prefetchErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.prefetchFile = tmpFile
|
||||||
|
f.prefetchFilePath = tmpFile.Name()
|
||||||
|
|
||||||
buf := make([]byte, 32*1024) // 32KB buffer
|
buf := make([]byte, 32*1024) // 32KB buffer
|
||||||
reader := NewTimeoutReader(resp.Body, readTimeout)
|
reader := f.responseReader(resp)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -617,7 +631,11 @@ func (f *Fetcher) cleanupPrefetchFile() {
|
|||||||
f.prefetchFile = nil
|
f.prefetchFile = nil
|
||||||
}
|
}
|
||||||
if f.prefetchFilePath != "" {
|
if f.prefetchFilePath != "" {
|
||||||
|
if f.ctl != nil {
|
||||||
|
f.ctl.TempFiles.Remove(f.prefetchFilePath)
|
||||||
|
} else {
|
||||||
os.Remove(f.prefetchFilePath)
|
os.Remove(f.prefetchFilePath)
|
||||||
|
}
|
||||||
f.prefetchFilePath = ""
|
f.prefetchFilePath = ""
|
||||||
}
|
}
|
||||||
// The byte count is only reusable while the backing prefetch file exists.
|
// The byte count is only reusable while the backing prefetch file exists.
|
||||||
@@ -627,7 +645,31 @@ func (f *Fetcher) cleanupPrefetchFile() {
|
|||||||
f.resolveFallback.Store(false)
|
f.resolveFallback.Store(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Options may be replaced between Resolve and Start when creating a resolved task.
|
||||||
|
func (f *Fetcher) initOptions() error {
|
||||||
|
opts := f.meta.Opts
|
||||||
|
// Parse options
|
||||||
|
if err := base.ParseOptExtra[fhttp.OptsExtra](opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if opts.Extra == nil {
|
||||||
|
opts.Extra = &fhttp.OptsExtra{}
|
||||||
|
}
|
||||||
|
extra := opts.Extra.(*fhttp.OptsExtra)
|
||||||
|
if extra.Connections <= 0 {
|
||||||
|
extra.Connections = f.config.Connections
|
||||||
|
if extra.Connections <= 0 {
|
||||||
|
extra.Connections = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Fetcher) Start() error {
|
func (f *Fetcher) Start() error {
|
||||||
|
if err := f.initOptions(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
state := f.getState()
|
state := f.getState()
|
||||||
|
|
||||||
switch state {
|
switch state {
|
||||||
@@ -1329,7 +1371,7 @@ func (f *Fetcher) downloadChunkOnce(conn *connection, client *http.Client, buf [
|
|||||||
f.slowStart.onConnectSuccess()
|
f.slowStart.onConnectSuccess()
|
||||||
}
|
}
|
||||||
|
|
||||||
reader := NewTimeoutReader(resp.Body, readTimeout)
|
reader := f.responseReader(resp)
|
||||||
var responseBytesRead int64
|
var responseBytesRead int64
|
||||||
for {
|
for {
|
||||||
if conn.ctx.Err() != nil {
|
if conn.ctx.Err() != nil {
|
||||||
@@ -1711,7 +1753,7 @@ func (f *Fetcher) runConnectionWithResolveResp(conn *connection) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Download data from resolve response
|
// Download data from resolve response
|
||||||
reader := NewTimeoutReader(resp.Body, readTimeout)
|
reader := f.responseReader(resp)
|
||||||
for {
|
for {
|
||||||
if conn.ctx.Err() != nil {
|
if conn.ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
@@ -1828,7 +1870,7 @@ func (f *Fetcher) runConnectionFallback(conn *connection) {
|
|||||||
f.slowStart.onConnectSuccess()
|
f.slowStart.onConnectSuccess()
|
||||||
}
|
}
|
||||||
|
|
||||||
reader := NewTimeoutReader(resp.Body, readTimeout)
|
reader := f.responseReader(resp)
|
||||||
for {
|
for {
|
||||||
if conn.ctx.Err() != nil {
|
if conn.ctx.Err() != nil {
|
||||||
return conn.ctx.Err()
|
return conn.ctx.Err()
|
||||||
@@ -2451,3 +2493,12 @@ func (f *Fetcher) Progress() fetcher.Progress {
|
|||||||
func (f *Fetcher) Wait() error {
|
func (f *Fetcher) Wait() error {
|
||||||
return <-f.doneCh
|
return <-f.doneCh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generated output can pause while its producer waits for processing capacity.
|
||||||
|
// Its producer owns upstream deadlines; cancellation still closes the response.
|
||||||
|
func (f *Fetcher) responseReader(resp *http.Response) io.Reader {
|
||||||
|
if f.ctl != nil && f.ctl.ManagedProduction != nil && resp.Request != nil && f.ctl.ManagedProduction(resp.Request.URL.String()) {
|
||||||
|
return resp.Body
|
||||||
|
}
|
||||||
|
return NewTimeoutReader(resp.Body, readTimeout)
|
||||||
|
}
|
||||||
@@ -72,7 +72,7 @@ func (fm *FetcherManager) AutoRename() bool {
|
|||||||
|
|
||||||
func (fm *FetcherManager) DefaultConfig() any {
|
func (fm *FetcherManager) DefaultConfig() any {
|
||||||
return &config{
|
return &config{
|
||||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36",
|
UserAgent: DefaultUserAgent,
|
||||||
Connections: 16,
|
Connections: 16,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/internal/controller"
|
||||||
|
"github.com/GopeedLab/gopeed/internal/tempfiles"
|
||||||
|
)
|
||||||
|
|
||||||
|
type prefetchSetupBody struct {
|
||||||
|
io.Reader
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *prefetchSetupBody) Close() error {
|
||||||
|
b.closed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrefetchSetupFailureClosesResponse(t *testing.T) {
|
||||||
|
for _, closedScope := range []bool{false, true} {
|
||||||
|
name := "invalid_temp_directory"
|
||||||
|
if closedScope {
|
||||||
|
name = "downloader_already_closed"
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
scope := &tempfiles.Scope{}
|
||||||
|
dir := root
|
||||||
|
if closedScope {
|
||||||
|
if err := scope.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dir = filepath.Join(root, "file")
|
||||||
|
if err := os.WriteFile(dir, nil, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body := &prefetchSetupBody{Reader: strings.NewReader("unused response")}
|
||||||
|
f := &Fetcher{
|
||||||
|
ctl: &controller.Controller{TempDir: dir, TempFiles: scope},
|
||||||
|
resolveResp: &http.Response{Body: body},
|
||||||
|
}
|
||||||
|
f.asyncPrefetch()
|
||||||
|
if f.prefetchErr == nil {
|
||||||
|
t.Fatal("expected temporary-file initialization to fail")
|
||||||
|
}
|
||||||
|
if !body.closed {
|
||||||
|
t.Error("prefetch setup failure left the response body open")
|
||||||
|
}
|
||||||
|
if f.resolveResp != nil {
|
||||||
|
t.Error("prefetch retained its failed resolve response")
|
||||||
|
}
|
||||||
|
if !f.prefetchDone.Load() {
|
||||||
|
t.Error("prefetch did not report completion")
|
||||||
|
}
|
||||||
|
if closedScope {
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Errorf("late prefetch left temporary files: %v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Package tempfiles tracks temporary paths owned by one downloader, without
|
||||||
|
// requiring a private parent directory or scanning the shared system temp root.
|
||||||
|
package tempfiles
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Scope struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
paths map[string]struct{}
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track adopts a newly created unique path. Late creations are removed when
|
||||||
|
// shutdown has already begun. A nil scope leaves cleanup to the resource owner.
|
||||||
|
func (s *Scope) Track(path string) error {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.closed {
|
||||||
|
return errors.Join(io.ErrClosedPipe, os.RemoveAll(path))
|
||||||
|
}
|
||||||
|
if s.paths == nil {
|
||||||
|
s.paths = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
s.paths[path] = struct{}{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *Scope) Remove(path string) error {
|
||||||
|
if s == nil {
|
||||||
|
return os.RemoveAll(path)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if err := os.RemoveAll(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delete(s.paths, path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *Scope) Close() error {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.closed = true
|
||||||
|
var result error
|
||||||
|
for path := range s.paths {
|
||||||
|
if err := os.RemoveAll(path); err != nil {
|
||||||
|
result = errors.Join(result, err)
|
||||||
|
} else {
|
||||||
|
delete(s.paths, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package tempfiles
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSharedDirectoryOwnersAndLateCreation(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
a, b := &Scope{}, &Scope{}
|
||||||
|
own := func(scope *Scope) string {
|
||||||
|
t.Helper()
|
||||||
|
dir, err := os.MkdirTemp(root, "gopeed-media-")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := scope.Track(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
first, second := own(a), own(b)
|
||||||
|
if err := a.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(first); !os.IsNotExist(err) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(second); err != nil {
|
||||||
|
t.Fatal("removed other owner's path", err)
|
||||||
|
}
|
||||||
|
late, err := os.MkdirTemp(root, "gopeed-media-")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := a.Track(late); !errors.Is(err, io.ErrClosedPipe) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(late); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("late creation leaked", err)
|
||||||
|
}
|
||||||
|
if err := b.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentCreationAndShutdown(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
scope := &Scope{}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
dir, err := os.MkdirTemp(root, "gopeed-media-")
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := scope.Track(dir); err != nil && !errors.Is(err, io.ErrClosedPipe) {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
if err := scope.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil || len(entries) != 0 {
|
||||||
|
t.Fatal("shutdown leaked paths", entries, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Extension browser host configuration
|
||||||
|
|
||||||
|
The downloader wraps each provider with the extension's stable identity before
|
||||||
|
constructing the runtime. `OpenOptions.ProfileID`, `DataPath`, and `ProxyURL` are
|
||||||
|
host-only fields. `parseOpenOptions` never reads them from extension JavaScript.
|
||||||
|
The extension API is unchanged. The Flutter host uses the fork's public
|
||||||
|
`WebViewProfile`, `InAppWebViewSettings.profileId`, and profile-scoped
|
||||||
|
`CookieManager` APIs; the library has no Gopeed-specific method-channel bridge.
|
||||||
|
Only the Gopeed extension JavaScript boundary keeps these options host-only.
|
||||||
|
|
||||||
|
Profiles are persistent and shared across invocations/pages of the same extension.
|
||||||
|
Different identities use different native stores. Go providers use a SHA-256-based
|
||||||
|
path under `<StorageDir>/webview`; Apple RPC hosts use a stable UUID data store;
|
||||||
|
Android uses a named WebView profile. Cookie operations select that same store,
|
||||||
|
including `clearCookies`. Closing an execution closes its pages, not its profile.
|
||||||
|
Uninstall closes the extension's pages, prevents its old runtimes from opening
|
||||||
|
new pages, and removes its native profile before deleting the extension record.
|
||||||
|
Cleanup failures abort uninstall and can be retried. Reinstallation starts with
|
||||||
|
empty browser data; an ordinary extension update keeps the existing profile.
|
||||||
|
Windows/Linux delete the profile directory; Apple removes the named WebKit store.
|
||||||
|
Android cannot delete a profile already loaded by the current process: when
|
||||||
|
DELETE_BROWSING_DATA is supported it clears all site data immediately, and records
|
||||||
|
an empty-profile deletion in SharedPreferences for the next process start. A
|
||||||
|
reinstallation may reuse that cleared profile and cancels the pending deletion.
|
||||||
|
If the full-data clearing API is unavailable, uninstall fails with a restart
|
||||||
|
instruction; the pending profile is deleted at startup before it can be loaded.
|
||||||
|
No removal operation is exposed through extension JavaScript.
|
||||||
|
|
||||||
|
Existing shared browser data is not copied into every extension's new profile.
|
||||||
|
|
||||||
|
The downloader lazily owns one loopback forward proxy with HTTP and SOCKS5
|
||||||
|
listeners. Windows/Linux/Android use HTTP; Apple uses SOCKS5 because its HTTP
|
||||||
|
CONNECT configuration may bypass plain HTTP. The forwarder reads the current
|
||||||
|
Gopeed proxy config for each new request/tunnel and supports direct, system,
|
||||||
|
HTTP(S) upstream proxies with Basic authentication, and authenticated SOCKS5.
|
||||||
|
Credentials remain in Go and are never sent over the WebView RPC.
|
||||||
|
|
||||||
|
Changes affect new requests/connections. Existing CONNECT/SOCKS tunnels keep their
|
||||||
|
route until closed; opening another page in the same profile does not reset it.
|
||||||
|
SOCKS carries host/port rather than a full URL: system proxy/PAC selection for
|
||||||
|
Apple is host-based, with port 80 treated as HTTP and other ports as HTTPS.
|
||||||
|
Path-dependent PAC rules cannot be reproduced from a SOCKS connection.
|
||||||
|
|
||||||
|
Apple requires macOS 14 / iOS 17. Android checks MULTI_PROFILE and PROXY_OVERRIDE.
|
||||||
|
Unsupported hosts fail explicitly instead of silently sharing browser storage or
|
||||||
|
ignoring the proxy.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- `go test -race ./internal/webview/proxy ./pkg/download/engine/webview`
|
||||||
|
- `go test ./pkg/download -run 'TestWebViewProfileBound|TestDownloader_ExtensionRuntimeWebView'`
|
||||||
|
- Windows/Linux: `go test -tags webview -v ./internal/webview/goprovider` (Linux: run under `xvfb-run -a`).
|
||||||
|
- macOS: start the normal Flutter app, then run `go test -tags webview -v ./internal/webview/rpcprovider` with its socket address. See `rpcprovider/README.md`.
|
||||||
|
- In webview_go: `go test -tags webview_integration -v -count=1 .` (Linux: run under `xvfb-run -a`).
|
||||||
|
|
||||||
|
The shared provider contract covers HTTP/HTTPS proxy routing, concurrent pages,
|
||||||
|
HttpOnly cookies, LocalStorage, IndexedDB, profile-scoped clearing, reopening,
|
||||||
|
and removal without affecting another profile. HTTPS routing is checked with a
|
||||||
|
rejecting upstream tunnel; no test-only certificate bypass is added to the app.
|
||||||
|
Go proxy unit tests separately cover successful TLS forwarding and authentication.
|
||||||
|
These cases run in the existing `test.yml` WebView jobs; existing mobile build jobs
|
||||||
|
compile the Android/iOS bridge. No separate Flutter test application is required.
|
||||||
|
|
||||||
|
## Extension events and host environment
|
||||||
|
|
||||||
|
`gopeed.host.env.version` is the host's build version (`dev` for development
|
||||||
|
builds). `gopeed.info.version` continues to identify the extension's own version.
|
||||||
|
`gopeed.host.env.os` and `gopeed.host.env.arch` match the REST info endpoint's `os` and
|
||||||
|
`arch`: the host's Go `runtime.GOOS` and `runtime.GOARCH` values (for example,
|
||||||
|
`windows` / `amd64` or `darwin` / `arm64`).
|
||||||
|
See [extension-api.d.ts](extension-api.d.ts) for the event payloads.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const page = await gopeed.runtime.webview.open();
|
||||||
|
const unsubscribe = await page.on("url-changed", ({ url, sameDocument }) => {
|
||||||
|
gopeed.logger.info(url, sameDocument);
|
||||||
|
});
|
||||||
|
await page.on("closed", ({ reason }) => gopeed.logger.info(reason));
|
||||||
|
await page.addInitScript("globalThis.extensionReady = true");
|
||||||
|
await page.goto("https://example.com");
|
||||||
|
unsubscribe();
|
||||||
|
await page.close();
|
||||||
|
```
|
||||||
|
|
||||||
|
Registration resolves only after the listener is installed; it does not replay
|
||||||
|
current state. `url-changed` reports actual address changes, including hash,
|
||||||
|
pushState, replaceState and history navigation. `load` follows the main document's
|
||||||
|
`window.load`, including reloads, and does not wait for business-level async work.
|
||||||
|
Iframe loads and subresource failures do not become main-page notifications.
|
||||||
|
`closed` distinguishes the user's close action from API closure and is terminal.
|
||||||
|
Unsubscribe is idempotent. Notifications cannot block navigation; the engine does
|
||||||
|
not await promises returned by handlers. Catch async errors inside the handler.
|
||||||
|
|
||||||
|
`addInitScript` applies to future document contexts only. Use `execute` to run
|
||||||
|
code on the current page. Executable functions are serialized and cannot capture
|
||||||
|
extension-side lexical variables; pass values explicitly as arguments.
|
||||||
|
|
||||||
|
The RPC host uses an NDJSON `page.events` response whose first record is
|
||||||
|
`{"ready":true}`. Subsequent records are `{event,data}`. Closing a page sends the
|
||||||
|
terminal record before ending the stream. The native backend uses document-start
|
||||||
|
notifications for main-document URL/load events and native callbacks for
|
||||||
|
navigation failures and user closure.
|
||||||
|
|
||||||
|
Additional validation:
|
||||||
|
|
||||||
|
- `go test -race ./pkg/download/engine/webview ./internal/webview/rpcprovider`
|
||||||
|
- `go test ./pkg/download -run 'TestExtension(WebViewEvents|InfoHostVersion)'`
|
||||||
|
- macOS native: `go test -tags webview_native ./internal/webview/goprovider -run TestProviderEvents`
|
||||||
|
- Running Flutter host: `go test -tags webview ./internal/webview/rpcprovider -run TestProviderEvents`
|
||||||
Vendored
+60
@@ -0,0 +1,60 @@
|
|||||||
|
/** Public extension API additions. info.version remains the extension version. */
|
||||||
|
export interface ExtensionInfo {
|
||||||
|
identity: string;
|
||||||
|
name: string;
|
||||||
|
author: string;
|
||||||
|
title: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Available as gopeed.host. */
|
||||||
|
export interface Host {
|
||||||
|
env: HostEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostEnv {
|
||||||
|
/** Host build version; "dev" for development builds. */
|
||||||
|
version: string;
|
||||||
|
/** Host OS and architecture, matching the REST info endpoint (Go GOOS/GOARCH). */
|
||||||
|
os: string;
|
||||||
|
arch: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Functions are serialized; they cannot capture extension-side variables. */
|
||||||
|
export type WebviewExecutable<T = unknown> =
|
||||||
|
| string
|
||||||
|
| ((...args: unknown[]) => T | Promise<T>);
|
||||||
|
|
||||||
|
export type WebviewUnsubscribe = () => void;
|
||||||
|
|
||||||
|
/** Navigation and load notifications are for the main document only. */
|
||||||
|
export interface WebviewEventMap {
|
||||||
|
"url-changed": { url: string; sameDocument: boolean };
|
||||||
|
/** window.load, including reloads, excluding hash/History API navigation. */
|
||||||
|
"load": { url: string };
|
||||||
|
"load-error": { url: string; message: string };
|
||||||
|
"closed": { reason: "user" | "api" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebviewGotoOptions {
|
||||||
|
timeoutMs?: number;
|
||||||
|
waitUntil?: "load" | "domcontentloaded";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** These members extend the existing WebviewPage API. */
|
||||||
|
export interface WebviewPage {
|
||||||
|
/**
|
||||||
|
* Resolves when listening is ready. Does not replay current state.
|
||||||
|
* Register before goto() to observe navigation. Unsubscribe is idempotent.
|
||||||
|
* Closed is delivered once, then listeners are released automatically.
|
||||||
|
* Callbacks cannot cancel navigation; returned promises are not awaited.
|
||||||
|
* Callers handle errors in asynchronous callbacks.
|
||||||
|
*/
|
||||||
|
on<K extends keyof WebviewEventMap>(
|
||||||
|
event: K,
|
||||||
|
handler: (data: WebviewEventMap[K]) => void,
|
||||||
|
): Promise<WebviewUnsubscribe>;
|
||||||
|
/** Runs at document creation on future navigations, never immediately. */
|
||||||
|
addInitScript(script: string): Promise<void>;
|
||||||
|
goto(url: string, opts?: WebviewGotoOptions): Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build cgo && darwin && webview_native
|
||||||
|
|
||||||
|
package goprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/GopeedLab/gopeed/internal/webview/integrationtest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) { os.Exit(RunMainThreadLoop(m.Run)) }
|
||||||
|
func TestProviderEvents(t *testing.T) { integrationtest.RunEventContract(t, New()) }
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
//go:build cgo
|
||||||
|
|
||||||
|
package goprovider
|
||||||
|
|
||||||
|
import (
|
||||||
|
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
|
||||||
|
webview "github.com/GopeedLab/webview_go"
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInternalErrorDocumentDoesNotReportPageEvents(t *testing.T) {
|
||||||
|
vm := goja.New()
|
||||||
|
if _, err := vm.RunString(`
|
||||||
|
var window = globalThis;
|
||||||
|
window.top = window;
|
||||||
|
var location = {protocol: "chrome-error:", href: "chrome-error://chromewebdata/"};
|
||||||
|
var notifications = [];
|
||||||
|
var listeners = [];
|
||||||
|
function notify(payload) { notifications.push(payload); }
|
||||||
|
window.addEventListener = (name) => listeners.push(name);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := vm.RunString(buildLoadNotifyScript("notify")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"notifications", "listeners"} {
|
||||||
|
value, err := vm.RunString(name + ".length")
|
||||||
|
if err != nil || value.ToInteger() != 0 {
|
||||||
|
t.Fatalf("internal error page registered %s: %v (%v)", name, value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The provider must wait for the native failure, preserving the failed URL.
|
||||||
|
page := newPageWrapper(enginewebview.OpenOptions{})
|
||||||
|
got := make(chan string, 1)
|
||||||
|
_, err := page.On("load-error", func(e enginewebview.Event) { got <- e.Data["url"].(string) })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
page.handleNativeEvent(webview.Event{Name: "load-error", URL: "https://example.test/failure", Message: "TLS handshake failed"})
|
||||||
|
if err := page.waitForNavigation(time.Second, "load"); err == nil {
|
||||||
|
t.Fatal("native failure completed navigation successfully")
|
||||||
|
}
|
||||||
|
if url := <-got; url != "https://example.test/failure" {
|
||||||
|
t.Fatal(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
//go:build linux && cgo
|
||||||
|
|
||||||
|
package goprovider
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo pkg-config: gtk+-3.0
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
static void pumpGTK(void) {
|
||||||
|
// Bound each batch so Go creation/removal requests cannot be starved.
|
||||||
|
for (int i = 0; i < 64 && g_main_context_pending(NULL); ++i)
|
||||||
|
g_main_context_iteration(NULL, FALSE);
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var gtkThreadOnce sync.Once
|
||||||
|
var gtkThreadTasks = make(chan func())
|
||||||
|
|
||||||
|
func RunMainThreadLoop(run func() int) int { return run() }
|
||||||
|
|
||||||
|
func postMainThreadTask(task func()) bool {
|
||||||
|
gtkThreadOnce.Do(func() {
|
||||||
|
go func() {
|
||||||
|
runtime.LockOSThread()
|
||||||
|
// WebKit must always use its original UI thread, including after
|
||||||
|
// the last page closes. A page cannot stop this shared event loop.
|
||||||
|
ticker := time.NewTicker(10 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case task := <-gtkThreadTasks:
|
||||||
|
task()
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
C.pumpGTK()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
gtkThreadTasks <- task
|
||||||
|
return true
|
||||||
|
}
|
||||||
Loaded 50 of 231 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user