mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-10 02:20:28 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45cf62f88d | ||
|
|
6e9d3499d9 | ||
|
|
b6d60f6bdf | ||
|
|
e1684e755a | ||
|
|
e63cfca82a | ||
|
|
85ef8d3be4 | ||
|
|
bf2ccdb5a7 | ||
|
|
a75bfd6118 | ||
|
|
a5d8713ff7 | ||
|
|
81518d794f | ||
|
|
7c2b81caf7 | ||
|
|
2221332243 | ||
|
|
dc407434d0 | ||
|
|
1c2e86a991 | ||
|
|
c78dc5f18f | ||
|
|
8ea5abb5c3 | ||
|
|
d1a4067dfa | ||
|
|
a1928d202f | ||
|
|
7376e85895 | ||
|
|
eb79dd5ac5 | ||
|
|
5b945b16c1 | ||
|
|
80e60f4190 | ||
|
|
3087796ef5 | ||
|
|
99de0aaf9b |
No files matched your search
@@ -5,7 +5,7 @@ on:
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.19
|
||||
FLUTTER_VERSION: 3.7.12
|
||||
FLUTTER_VERSION: 3.10.6
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -28,7 +28,7 @@ on:
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.19
|
||||
FLUTTER_VERSION: 3.7.12
|
||||
FLUTTER_VERSION: 3.10.6
|
||||
|
||||
jobs:
|
||||
# lint:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
name: 'translator'
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
discussion:
|
||||
types: [created, edited]
|
||||
discussion_comment:
|
||||
types: [created, edited]
|
||||
pull_request_target:
|
||||
types: [opened, edited]
|
||||
pull_request_review_comment:
|
||||
types: [created, edited]
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
permissions:
|
||||
issues: write
|
||||
discussions: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: lizheming/[email protected]
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
IS_MODIFY_TITLE: true
|
||||
APPEND_TRANSLATION: true
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM cirrusci/flutter:3.7.7 AS flutter
|
||||
FROM instrumentisto/flutter:3.10.6 AS flutter
|
||||
WORKDIR /app
|
||||
COPY ./ui/flutter/pubspec.yaml ./ui/flutter/pubspec.lock ./
|
||||
RUN flutter pub get
|
||||
@@ -18,4 +18,4 @@ LABEL maintainer="monkeyWie"
|
||||
WORKDIR /app
|
||||
COPY --from=go /app/dist/gopeed ./
|
||||
EXPOSE 9999
|
||||
ENTRYPOINT ["./gopeed"]
|
||||
ENTRYPOINT ["./gopeed"]
|
||||
@@ -44,7 +44,7 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||
#### Start directly
|
||||
|
||||
```bash
|
||||
docker run -d -p 9999:9999 -v /path/to/download:/download liwei2633/gopeed
|
||||
docker run -d -p 9999:9999 -v /path/to/download:/root/Downloads liwei2633/gopeed
|
||||
```
|
||||
|
||||
#### Using Docker Compose
|
||||
@@ -57,8 +57,6 @@ docker-compose up -d
|
||||
|
||||
When the docker container is running, you can access the web page through `http://localhost:9999`.
|
||||
|
||||
> Tip: Modify the download path to `/download` on the setting page to access the downloaded files on the host.
|
||||
|
||||
## Donate
|
||||
|
||||
If you like this project, please consider [donating](/.donate/index.md#donate) to support the development of this project, thank you!
|
||||
@@ -76,7 +74,7 @@ This project is divided into two parts, the front end uses `flutter`, the back e
|
||||
### Environment
|
||||
|
||||
1. Golang 1.19+
|
||||
2. Flutter 3.7+
|
||||
2. Flutter 3.10+
|
||||
|
||||
### Clone
|
||||
|
||||
@@ -92,7 +90,7 @@ Please refer to [CONTRIBUTING.md](/CONTRIBUTING.md)
|
||||
|
||||
#### Desktop
|
||||
|
||||
First, you need to configure the environment according to the [flutter desktop official website document](https://docs.flutter.dev/development/platform-integration/desktop), and then you need to prepare the `cgo` environment, which can be searched for yourself.
|
||||
First, you need to configure the environment according to the official [Flutter desktop website documention](https://docs.flutter.dev/development/platform-integration/desktop), then you will need to ensure the cgo environment is set up accordingly. For detailed instructions on setting up the cgo environment, please refer to relevant resources available online.
|
||||
|
||||
command:
|
||||
|
||||
|
||||
+2
-4
@@ -44,7 +44,7 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||
#### 直接始める
|
||||
|
||||
```bash
|
||||
docker run -d -p 9999:9999 -v /path/to/download:/download liwei2633/gopeed
|
||||
docker run -d -p 9999:9999 -v /path/to/download:/root/Downloads liwei2633/gopeed
|
||||
```
|
||||
|
||||
#### Docker Compose の使用
|
||||
@@ -57,8 +57,6 @@ docker-compose up -d
|
||||
|
||||
docker コンテナが起動しているときは、`http://localhost:9999` からウェブページにアクセスできます。
|
||||
|
||||
> ヒント: 設定ページでダウンロードパスを `/download` に変更すると、ホスト上でダウンロードしたファイルにアクセスできます。
|
||||
|
||||
## 寄付
|
||||
|
||||
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](/.donate/index.md#donate)をご検討ください!
|
||||
@@ -76,7 +74,7 @@ docker コンテナが起動しているときは、`http://localhost:9999` か
|
||||
### 環境
|
||||
|
||||
1. Go 言語 1.19+
|
||||
2. Flutter 3.7+
|
||||
2. Flutter 3.10+
|
||||
|
||||
### クローン
|
||||
|
||||
|
||||
+2
-3
@@ -44,7 +44,7 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||
#### 直接运行
|
||||
|
||||
```bash
|
||||
docker run -d -p 9999:9999 -v /path/to/download:/download liwei2633/gopeed
|
||||
docker run -d -p 9999:9999 -v /path/to/download:/root/Downloads liwei2633/gopeed
|
||||
```
|
||||
|
||||
#### 使用 Docker Compose
|
||||
@@ -56,7 +56,6 @@ docker-compose up -d
|
||||
#### 访问服务
|
||||
|
||||
当 docker 容器运行时,可以通过 `http://localhost:9999` 访问 web 页面。
|
||||
> 提示:在设置页面把下载路径修改为 `/download` 以便在宿主机访问下载完的文件。
|
||||
|
||||
## 打赏
|
||||
|
||||
@@ -75,7 +74,7 @@ docker-compose up -d
|
||||
### 环境要求
|
||||
|
||||
1. Golang 1.19+
|
||||
2. Flutter 3.7+
|
||||
2. Flutter 3.10+
|
||||
|
||||
### 克隆项目
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type args struct {
|
||||
Address *string `json:"address"`
|
||||
Port *int `json:"port"`
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
ApiToken *string `json:"apiToken"`
|
||||
|
||||
configPath *string
|
||||
}
|
||||
|
||||
func parse() *args {
|
||||
var cliArgs args
|
||||
cliArgs.Address = flag.String("A", "0.0.0.0", "Bind Address")
|
||||
cliArgs.Port = flag.Int("P", 9999, "Bind Port")
|
||||
cliArgs.Username = flag.String("u", "gopeed", "HTTP Basic Auth Username")
|
||||
cliArgs.Password = flag.String("p", "", "HTTP Basic Auth Password")
|
||||
cliArgs.ApiToken = flag.String("T", "", "API token, that can only be used when basic authentication is enabled.")
|
||||
cliArgs.configPath = flag.String("c", "./config.json", "Config file path")
|
||||
flag.Parse()
|
||||
|
||||
fmt.Println("parse")
|
||||
|
||||
// args priority: config file > cli args
|
||||
cfgArgs := loadConfig(*cliArgs.configPath)
|
||||
if cfgArgs.Address == nil {
|
||||
cfgArgs.Address = cliArgs.Address
|
||||
}
|
||||
if cfgArgs.Port == nil {
|
||||
cfgArgs.Port = cliArgs.Port
|
||||
}
|
||||
if cfgArgs.Username == nil {
|
||||
cfgArgs.Username = cliArgs.Username
|
||||
}
|
||||
if cfgArgs.Password == nil {
|
||||
cfgArgs.Password = cliArgs.Password
|
||||
}
|
||||
if cfgArgs.ApiToken == nil {
|
||||
cfgArgs.ApiToken = cliArgs.ApiToken
|
||||
}
|
||||
return cfgArgs
|
||||
}
|
||||
|
||||
func loadConfig(path string) *args {
|
||||
var args args
|
||||
|
||||
if !filepath.IsAbs(path) {
|
||||
dir, err := filepath.Abs(filepath.Dir(os.Args[0]))
|
||||
if err != nil {
|
||||
return &args
|
||||
}
|
||||
path = filepath.Join(dir, path)
|
||||
}
|
||||
file, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &args
|
||||
}
|
||||
fmt.Println("config file read failed, reason:" + err.Error())
|
||||
return &args
|
||||
}
|
||||
if err = json.Unmarshal(file, &args); err != nil {
|
||||
fmt.Println("config file parse failed, reason:" + err.Error())
|
||||
return &args
|
||||
}
|
||||
fmt.Printf("config file loaded: %s\n", path)
|
||||
return &args
|
||||
}
|
||||
+22
-5
@@ -5,6 +5,7 @@ package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"github.com/GopeedLab/gopeed/cmd"
|
||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||
"io/fs"
|
||||
@@ -18,12 +19,28 @@ func main() {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
args := parse()
|
||||
var webBasicAuth *model.WebBasicAuth
|
||||
if isNotBlank(args.Username) && isNotBlank(args.Password) {
|
||||
webBasicAuth = &model.WebBasicAuth{
|
||||
Username: *args.Username,
|
||||
Password: *args.Password,
|
||||
}
|
||||
}
|
||||
|
||||
cfg := &model.StartConfig{
|
||||
Network: "tcp",
|
||||
Address: "0.0.0.0:9999",
|
||||
Storage: model.StorageBolt,
|
||||
WebEnable: true,
|
||||
WebFS: sub,
|
||||
Network: "tcp",
|
||||
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
|
||||
Storage: model.StorageBolt,
|
||||
ApiToken: *args.ApiToken,
|
||||
WebEnable: true,
|
||||
WebFS: sub,
|
||||
WebBasicAuth: webBasicAuth,
|
||||
}
|
||||
cmd.Start(cfg)
|
||||
}
|
||||
|
||||
func isNotBlank(str *string) bool {
|
||||
return str != nil && *str != ""
|
||||
}
|
||||
+1
-1
@@ -7,5 +7,5 @@ services:
|
||||
- 9999:9999 # HTTP port (host:container)
|
||||
image: liwei2633/gopeed
|
||||
volumes:
|
||||
- ~/Downloads:/download # replace with your prefer path
|
||||
- ~/Downloads:/root/Downloads # replace with your prefer path
|
||||
restart: unless-stopped
|
||||
@@ -4,7 +4,7 @@ go 1.19
|
||||
|
||||
require (
|
||||
github.com/anacrolix/missinggo/v2 v2.7.2-0.20230527121029-a582b4f397b9
|
||||
github.com/anacrolix/torrent v1.52.3
|
||||
github.com/anacrolix/torrent v1.52.5
|
||||
github.com/gorilla/handlers v1.5.1
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/matoous/go-nanoid/v2 v2.0.0
|
||||
@@ -13,8 +13,6 @@ require (
|
||||
golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde
|
||||
)
|
||||
|
||||
replace github.com/anacrolix/torrent => github.com/monkeyWie/torrent v1.52.4-alpha
|
||||
|
||||
require (
|
||||
crawshaw.io/sqlite v0.3.3-0.20220618202545-d1964889ea3c // indirect
|
||||
github.com/RoaringBitmap/roaring v1.2.3 // indirect
|
||||
@@ -24,7 +22,7 @@ require (
|
||||
github.com/anacrolix/dht/v2 v2.19.2-0.20221121215055-066ad8494444 // indirect
|
||||
github.com/anacrolix/envpprof v1.2.1 // indirect
|
||||
github.com/anacrolix/generics v0.0.0-20230428105757-683593396d68 // indirect
|
||||
github.com/anacrolix/go-libutp v1.2.0 // indirect
|
||||
github.com/anacrolix/go-libutp v1.3.1 // indirect
|
||||
github.com/anacrolix/log v0.14.0 // indirect
|
||||
github.com/anacrolix/missinggo v1.3.0 // indirect
|
||||
github.com/anacrolix/missinggo/perf v1.0.0 // indirect
|
||||
|
||||
@@ -35,12 +35,12 @@ github.com/anacrolix/envpprof v1.2.1 h1:25TJe6t/i0AfzzldiGFKCpD+s+dk8lONBcacJZB2
|
||||
github.com/anacrolix/envpprof v1.2.1/go.mod h1:My7T5oSqVfEn4MD4Meczkw/f5lSIndGAKu/0SM/rkf4=
|
||||
github.com/anacrolix/generics v0.0.0-20230428105757-683593396d68 h1:fyXlBfnlFzZSFckJ8QLb2lfmWfY++4RiUnae7ZMuv0A=
|
||||
github.com/anacrolix/generics v0.0.0-20230428105757-683593396d68/go.mod h1:ff2rHB/joTV03aMSSn/AZNnaIpUw0h3njetGsaXcMy8=
|
||||
github.com/anacrolix/go-libutp v1.2.0 h1:sjxoB+/ARiKUR7IK/6wLWyADIBqGmu1fm0xo+8Yy7u0=
|
||||
github.com/anacrolix/go-libutp v1.2.0/go.mod h1:RrJ3KcaDcf9Jqp33YL5V/5CBEc6xMc7aJL8wXfuWL50=
|
||||
github.com/anacrolix/go-libutp v1.3.1 h1:idJzreNLl+hNjGC3ZnUOjujEaryeOGgkwHLqSGoige0=
|
||||
github.com/anacrolix/go-libutp v1.3.1/go.mod h1:heF41EC8kN0qCLMokLBVkB8NXiLwx3t8R8810MTNI5o=
|
||||
github.com/anacrolix/log v0.3.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=
|
||||
github.com/anacrolix/log v0.6.0/go.mod h1:lWvLTqzAnCWPJA08T2HCstZi0L1y2Wyvm3FJgwU9jwU=
|
||||
github.com/anacrolix/log v0.10.0/go.mod h1:s5yBP/j046fm9odtUTbHOfDUq/zh1W8OkPpJtnX0oQI=
|
||||
github.com/anacrolix/log v0.10.1-0.20220123034749-3920702c17f8/go.mod h1:GmnE2c0nvz8pOIPUSC9Rawgefy1sDXqposC2wgtBZE4=
|
||||
github.com/anacrolix/log v0.13.1/go.mod h1:D4+CvN8SnruK6zIFS/xPoRJmtvtnxs+CSfDQ+BFxZ68=
|
||||
github.com/anacrolix/log v0.14.0 h1:mYhTSemILe/Z8tIxbGdTIWWpPspI8W/fhZHpoFbDaL0=
|
||||
github.com/anacrolix/log v0.14.0/go.mod h1:1OmJESOtxQGNMlUO5rcv96Vpp9mfMqXXbe2RdinFLdY=
|
||||
github.com/anacrolix/lsan v0.0.0-20211126052245-807000409a62 h1:P04VG6Td13FHMgS5ZBcJX23NPC/fiC4cp9bXwYujdYM=
|
||||
@@ -73,6 +73,8 @@ github.com/anacrolix/sync v0.4.0/go.mod h1:BbecHL6jDSExojhNtgTFSBcdGerzNc64tz3DC
|
||||
github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
|
||||
github.com/anacrolix/tagflag v1.0.0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
|
||||
github.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CMUMIxqHIG8=
|
||||
github.com/anacrolix/torrent v1.52.5 h1:jWowdx+EU6zFVfBwmnL0d3H4J6vTFEGOrHI35YdfIT8=
|
||||
github.com/anacrolix/torrent v1.52.5/go.mod h1:CcM8oPMYye5J42cSqJrmUpqwRFgSsJQ1jCEHwygqnqQ=
|
||||
github.com/anacrolix/upnp v0.1.3-0.20220123035249-922794e51c96 h1:QAVZ3pN/J4/UziniAhJR2OZ9Ox5kOY2053tBbbqUPYA=
|
||||
github.com/anacrolix/upnp v0.1.3-0.20220123035249-922794e51c96/go.mod h1:Wa6n8cYIdaG35x15aH3Zy6d03f7P728QfdcDeD/IEOs=
|
||||
github.com/anacrolix/utp v0.1.0 h1:FOpQOmIwYsnENnz7tAGohA+r6iXpRjrq8ssKSre2Cp4=
|
||||
@@ -210,8 +212,6 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/monkeyWie/torrent v1.52.4-alpha h1:iir8NMyLkv6X/D4skGUx8Htb8q57cPk9A6bYlRfrrqA=
|
||||
github.com/monkeyWie/torrent v1.52.4-alpha/go.mod h1:yBUcBuIOlttGEgAW/6gamzQGD+k2+wUh65fOPPzxvC4=
|
||||
github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
|
||||
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
||||
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
||||
@@ -365,7 +365,6 @@ golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTk
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20180524181706-dfa909b99c79/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -441,6 +440,7 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -448,6 +448,7 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/time v0.0.0-20220609170525-579cf78fd858 h1:Dpdu/EMxGMFgq0CeYMh4fazTD2vtlZRYE7wyynxJb9U=
|
||||
golang.org/x/time v0.0.0-20220609170525-579cf78fd858/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
|
||||
"github.com/GopeedLab/gopeed/pkg/util"
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
@@ -68,7 +70,11 @@ func (f *Fetcher) initClient() (err error) {
|
||||
}
|
||||
|
||||
func (f *Fetcher) Resolve(req *base.Request) error {
|
||||
if err := f.addTorrent(req.URL); err != nil {
|
||||
if err := base.ParseReqExtra[bt.ReqExtra](req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := f.addTorrent(req); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
@@ -122,7 +128,7 @@ func (f *Fetcher) Create(opts *base.Options) (err error) {
|
||||
|
||||
func (f *Fetcher) Start() (err error) {
|
||||
if !f.torrentReady.Load() {
|
||||
if err = f.addTorrent(f.meta.Req.URL); err != nil {
|
||||
if err = f.addTorrent(f.meta.Req); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -211,15 +217,24 @@ func (f *Fetcher) Progress() fetcher.Progress {
|
||||
return f.progress
|
||||
}
|
||||
|
||||
func (f *Fetcher) addTorrent(url string) (err error) {
|
||||
func (f *Fetcher) addTorrent(req *base.Request) (err error) {
|
||||
if err = f.initClient(); err != nil {
|
||||
return
|
||||
}
|
||||
schema := util.ParseSchema(url)
|
||||
schema := util.ParseSchema(req.URL)
|
||||
if schema == "MAGNET" {
|
||||
f.torrent, err = client.AddMagnet(url)
|
||||
f.torrent, err = client.AddMagnet(req.URL)
|
||||
} else if schema == "APPLICATION/X-BITTORRENT" {
|
||||
_, data := util.ParseDataUri(req.URL)
|
||||
buf := bytes.NewBuffer(data)
|
||||
var metaInfo *metainfo.MetaInfo
|
||||
metaInfo, err = metainfo.Load(buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.torrent, err = client.AddTorrent(metaInfo)
|
||||
} else {
|
||||
f.torrent, err = client.AddTorrentFromFile(url)
|
||||
f.torrent, err = client.AddTorrentFromFile(req.URL)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
@@ -229,9 +244,25 @@ func (f *Fetcher) addTorrent(url string) (err error) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// use map to deduplicate
|
||||
trackers := make(map[string]bool)
|
||||
if req.Extra != nil {
|
||||
extra := req.Extra.(*bt.ReqExtra)
|
||||
if len(extra.Trackers) > 0 {
|
||||
for _, tracker := range extra.Trackers {
|
||||
trackers[tracker] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if exist && len(cfg.Trackers) > 0 {
|
||||
announceList := make([][]string, 0)
|
||||
for _, tracker := range cfg.Trackers {
|
||||
trackers[tracker] = true
|
||||
}
|
||||
}
|
||||
if len(trackers) > 0 {
|
||||
announceList := make([][]string, 0)
|
||||
for tracker := range trackers {
|
||||
announceList = append(announceList, []string{tracker})
|
||||
}
|
||||
f.torrent.AddTrackers(announceList)
|
||||
@@ -244,7 +275,7 @@ func (f *Fetcher) addTorrent(url string) (err error) {
|
||||
type FetcherBuilder struct {
|
||||
}
|
||||
|
||||
var schemes = []string{"FILE", "MAGNET"}
|
||||
var schemes = []string{"FILE", "MAGNET", "APPLICATION/X-BITTORRENT"}
|
||||
|
||||
func (fb *FetcherBuilder) Schemes() []string {
|
||||
return schemes
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package bt
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"github.com/GopeedLab/gopeed/internal/controller"
|
||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||
"github.com/GopeedLab/gopeed/internal/test"
|
||||
"github.com/GopeedLab/gopeed/pkg/base"
|
||||
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
@@ -14,6 +17,39 @@ func TestFetcher_Resolve_Torrent(t *testing.T) {
|
||||
doResolve(t, buildFetcher())
|
||||
}
|
||||
|
||||
func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
|
||||
fetcher := buildFetcher()
|
||||
buf, err := os.ReadFile("./testdata/ubuntu-22.04-live-server-amd64.iso.torrent")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// convert to data uri
|
||||
dataUri := "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(buf)
|
||||
err = fetcher.Resolve(&base.Request{
|
||||
URL: dataUri,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
want := &base.Resource{
|
||||
Name: "ubuntu-22.04-live-server-amd64.iso",
|
||||
Size: 1466714112,
|
||||
Range: true,
|
||||
RootDir: "ubuntu-22.04-live-server-amd64.iso",
|
||||
Files: []*base.FileInfo{
|
||||
{
|
||||
Name: "ubuntu-22.04-live-server-amd64.iso",
|
||||
Size: 1466714112,
|
||||
},
|
||||
},
|
||||
Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed",
|
||||
}
|
||||
if !reflect.DeepEqual(want, fetcher.Meta().Res) {
|
||||
t.Errorf("Resolve() got = %v, want %v", fetcher.Meta().Res, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_Config(t *testing.T) {
|
||||
doResolve(t, buildConfigFetcher())
|
||||
}
|
||||
@@ -21,6 +57,12 @@ func TestFetcher_Config(t *testing.T) {
|
||||
func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "./testdata/ubuntu-22.04-live-server-amd64.iso.torrent",
|
||||
Extra: bt.ReqExtra{
|
||||
Trackers: []string{
|
||||
"udp://tracker.birkenwald.de:6969/announce",
|
||||
"udp://tracker.bitsearch.to:1337/announce",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RequestError struct {
|
||||
@@ -85,13 +84,14 @@ func (f *Fetcher) Resolve(req *base.Request) error {
|
||||
return err
|
||||
}
|
||||
client := buildClient()
|
||||
// 只访问一个字节,测试资源是否支持Range请求
|
||||
// send Range request to check whether the server supports breakpoint continuation
|
||||
// just test one byte, Range: bytes=0-0
|
||||
httpReq.Header.Set(base.HttpHeaderRange, fmt.Sprintf(base.HttpHeaderRangeFormat, 0, 0))
|
||||
httpResp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 拿到响应头就关闭,不用加defer
|
||||
// close response body immediately
|
||||
httpResp.Body.Close()
|
||||
res := &base.Resource{
|
||||
Range: false,
|
||||
@@ -395,8 +395,7 @@ func buildClient() *http.Client {
|
||||
// Cookie handle
|
||||
jar, _ := cookiejar.New(nil)
|
||||
return &http.Client{
|
||||
Jar: jar,
|
||||
Timeout: time.Second * 60,
|
||||
Jar: jar,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,8 +47,9 @@ type Downloader struct {
|
||||
tasks []*Task
|
||||
listener Listener
|
||||
|
||||
lock *sync.Mutex
|
||||
closed atomic.Bool
|
||||
lock *sync.Mutex
|
||||
fetcherMapLock *sync.RWMutex
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func NewDownloader(cfg *DownloaderConfig) *Downloader {
|
||||
@@ -63,8 +64,9 @@ func NewDownloader(cfg *DownloaderConfig) *Downloader {
|
||||
fetchBuilders: make(map[string]fetcher.FetcherBuilder),
|
||||
fetcherMap: make(map[string]fetcher.Fetcher),
|
||||
|
||||
storage: cfg.Storage,
|
||||
lock: &sync.Mutex{},
|
||||
storage: cfg.Storage,
|
||||
lock: &sync.Mutex{},
|
||||
fetcherMapLock: &sync.RWMutex{},
|
||||
}
|
||||
for _, f := range cfg.FetchBuilders {
|
||||
for _, p := range f.Schemes() {
|
||||
@@ -169,9 +171,6 @@ func (d *Downloader) Setup() error {
|
||||
func (d *Downloader) parseFb(url string) (fetcher.FetcherBuilder, error) {
|
||||
schema := util.ParseSchema(url)
|
||||
fetchBuilder, ok := d.fetchBuilders[schema]
|
||||
if !ok {
|
||||
fetchBuilder, ok = d.fetchBuilders[util.FileSchema]
|
||||
}
|
||||
if ok {
|
||||
return fetchBuilder, nil
|
||||
}
|
||||
@@ -201,7 +200,9 @@ func (d *Downloader) Resolve(req *base.Request) (rr *ResolveResult, err error) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
d.fetcherMapLock.Lock()
|
||||
d.fetcherMap[rrId] = fetcher
|
||||
d.fetcherMapLock.Unlock()
|
||||
rr = &ResolveResult{
|
||||
ID: rrId,
|
||||
Res: fetcher.Meta().Res,
|
||||
@@ -221,11 +222,17 @@ func (d *Downloader) Create(rrId string, opts *base.Options) (taskId string, err
|
||||
if opts == nil {
|
||||
opts = &base.Options{}
|
||||
}
|
||||
d.fetcherMapLock.RLock()
|
||||
fetcher, ok := d.fetcherMap[rrId]
|
||||
d.fetcherMapLock.RUnlock()
|
||||
if !ok {
|
||||
return "", errors.New("invalid resource id")
|
||||
}
|
||||
defer delete(d.fetcherMap, rrId)
|
||||
defer func() {
|
||||
d.fetcherMapLock.Lock()
|
||||
delete(d.fetcherMap, rrId)
|
||||
d.fetcherMapLock.Unlock()
|
||||
}()
|
||||
|
||||
meta := fetcher.Meta()
|
||||
meta.Opts = opts
|
||||
@@ -521,7 +528,9 @@ func (d *Downloader) watch(task *Task) {
|
||||
d.emit(EventKeyDone, task)
|
||||
}
|
||||
d.emit(EventKeyFinally, task, err)
|
||||
d.notifyRunning()
|
||||
if task.Status == EventKeyDone {
|
||||
d.notifyRunning()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Downloader) restoreFetcher(task *Task) error {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package bt
|
||||
|
||||
type ReqExtra struct {
|
||||
Trackers []string `json:"trackers"`
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package model
|
||||
|
||||
import "io/fs"
|
||||
import (
|
||||
"encoding/base64"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
type Storage string
|
||||
|
||||
@@ -17,8 +20,9 @@ type StartConfig struct {
|
||||
StorageDir string `json:"storageDir"`
|
||||
ApiToken string `json:"apiToken"`
|
||||
|
||||
WebEnable bool
|
||||
WebFS fs.FS
|
||||
WebEnable bool
|
||||
WebFS fs.FS
|
||||
WebBasicAuth *WebBasicAuth
|
||||
}
|
||||
|
||||
func (cfg *StartConfig) Init() *StartConfig {
|
||||
@@ -39,3 +43,14 @@ func (cfg *StartConfig) Init() *StartConfig {
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
type WebBasicAuth struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// Authorization returns the value of the Authorization header to be used in HTTP requests.
|
||||
func (cfg *WebBasicAuth) Authorization() string {
|
||||
userId := cfg.Username + ":" + cfg.Password
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(userId))
|
||||
}
|
||||
+16
-5
@@ -98,14 +98,21 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
||||
r.PathPrefix("/").Handler(http.FileServer(http.FS(startCfg.WebFS)))
|
||||
}
|
||||
|
||||
if startCfg.ApiToken != "" {
|
||||
if startCfg.ApiToken != "" || (startCfg.WebEnable && startCfg.WebBasicAuth != nil) {
|
||||
r.Use(func(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Api-Token") != startCfg.ApiToken {
|
||||
WriteJson(w, model.NewErrorResult("invalid token", model.CodeUnauthorized))
|
||||
if startCfg.ApiToken != "" && r.Header.Get("X-Api-Token") == startCfg.ApiToken {
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
if startCfg.WebEnable && startCfg.WebBasicAuth != nil {
|
||||
if r.Header.Get("Authorization") == startCfg.WebBasicAuth.Authorization() {
|
||||
h.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("WWW-Authenticate", "Basic realm=\"gopeed web\"")
|
||||
}
|
||||
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -127,7 +134,11 @@ func ReadJson(r *http.Request, w http.ResponseWriter, v any) bool {
|
||||
}
|
||||
|
||||
func WriteJson(w http.ResponseWriter, v any) {
|
||||
WriteStatusJson(w, http.StatusOK, v)
|
||||
}
|
||||
|
||||
func WriteStatusJson(w http.ResponseWriter, statusCode int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.WriteHeader(statusCode)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
+46
-5
@@ -273,7 +273,7 @@ func TestDoProxy(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthorization(t *testing.T) {
|
||||
func TestApiToken(t *testing.T) {
|
||||
var cfg = &model.StartConfig{}
|
||||
cfg.Init()
|
||||
cfg.ApiToken = "123456"
|
||||
@@ -285,16 +285,57 @@ func TestAuthorization(t *testing.T) {
|
||||
Stop()
|
||||
}()
|
||||
|
||||
code, _ := doHttpRequest[any](http.MethodGet, "/api/v1/config", nil, nil)
|
||||
checkCode(code, model.CodeUnauthorized)
|
||||
status, _ := doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("TestApiToken() got = %v, want %v", status, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
code, _ = doHttpRequest[any](http.MethodGet, "/api/v1/config", map[string]string{
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
||||
"X-Api-Token": cfg.ApiToken,
|
||||
}, nil)
|
||||
checkCode(code, model.CodeOk)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("TestApiToken() got = %v, want %v", status, http.StatusOK)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestAuthorization(t *testing.T) {
|
||||
var cfg = &model.StartConfig{}
|
||||
cfg.Init()
|
||||
cfg.ApiToken = "123456"
|
||||
cfg.WebEnable = true
|
||||
cfg.WebBasicAuth = &model.WebBasicAuth{
|
||||
Username: "admin",
|
||||
Password: "123456",
|
||||
}
|
||||
fileListener := doStart(cfg)
|
||||
defer func() {
|
||||
if err := fileListener.Close(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
Stop()
|
||||
}()
|
||||
|
||||
status, _ := doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
||||
"Authorization": cfg.WebBasicAuth.Authorization(),
|
||||
}, nil)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK)
|
||||
}
|
||||
|
||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
||||
"X-Api-Token": cfg.ApiToken,
|
||||
}, nil)
|
||||
if status != http.StatusOK {
|
||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func doTest(handler func()) {
|
||||
testFunc := func(storage model.Storage) {
|
||||
var cfg = &model.StartConfig{}
|
||||
|
||||
@@ -88,3 +88,13 @@ func CreateDirIfNotExist(dir string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsExistsFile check file exists and is a file
|
||||
func IsExistsFile(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
// 判断路径是否存在,并且是一个文件
|
||||
if err == nil && !info.IsDir() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -199,3 +199,43 @@ func doCheckDuplicateAndRename(t *testing.T, exitsPaths []string, path string, e
|
||||
t.Errorf("CheckDuplicateAndRename() = %v, want %v", got, except)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExistsFile(t *testing.T) {
|
||||
type args struct {
|
||||
path string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exist",
|
||||
args: args{
|
||||
path: "./path.go",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "not exist",
|
||||
args: args{
|
||||
path: "./path_not_exist.go",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "is dir",
|
||||
args: args{
|
||||
path: "../util",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsExistsFile(tt.args.path); got != tt.want {
|
||||
t.Errorf("IsExistsFile() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+29
-3
@@ -1,13 +1,39 @@
|
||||
package util
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"encoding/base64"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const FileSchema = "FILE"
|
||||
|
||||
func ParseSchema(url string) string {
|
||||
index := strings.Index(url, ":")
|
||||
if index == -1 {
|
||||
// if no schema or windows path like C:\a.txt, return FILE
|
||||
if index == -1 || index == 1 {
|
||||
return FileSchema
|
||||
}
|
||||
return strings.ToUpper(url[:index])
|
||||
schema := url[:index]
|
||||
if schema == "data" {
|
||||
schema, _ = ParseDataUri(url)
|
||||
}
|
||||
return strings.ToUpper(schema)
|
||||
}
|
||||
|
||||
// ParseDataUri parses a data URI and returns the MIME type and decode data.
|
||||
func ParseDataUri(uri string) (string, []byte) {
|
||||
re := regexp.MustCompile(`^data:(.*);base64,(.*)$`)
|
||||
matches := re.FindStringSubmatch(uri)
|
||||
if len(matches) != 3 {
|
||||
return "", nil
|
||||
}
|
||||
mime := matches[1]
|
||||
base64Data := matches[2]
|
||||
// 解码Base64数据
|
||||
data, err := base64.StdEncoding.DecodeString(base64Data)
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
return mime, data
|
||||
}
|
||||
+82
-2
@@ -1,6 +1,10 @@
|
||||
package util
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/base64"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSchema(t *testing.T) {
|
||||
type args struct {
|
||||
@@ -35,7 +39,21 @@ func TestParseSchema(t *testing.T) {
|
||||
{
|
||||
name: "file-no-scheme",
|
||||
args: args{
|
||||
url: "/home/bt.torrent",
|
||||
url: "./url.go",
|
||||
},
|
||||
want: "FILE",
|
||||
},
|
||||
{
|
||||
name: "data-uri",
|
||||
args: args{
|
||||
url: "data:application/x-bittorrent;base64,test",
|
||||
},
|
||||
want: "APPLICATION/X-BITTORRENT",
|
||||
},
|
||||
{
|
||||
name: "windows-path",
|
||||
args: args{
|
||||
url: "D:\\bt.torrent",
|
||||
},
|
||||
want: "FILE",
|
||||
},
|
||||
@@ -48,3 +66,65 @@ func TestParseSchema(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDataUri(t *testing.T) {
|
||||
type args struct {
|
||||
uri string
|
||||
}
|
||||
type result struct {
|
||||
mime string
|
||||
data []byte
|
||||
}
|
||||
|
||||
testData := []byte("test")
|
||||
testData64 := base64.StdEncoding.EncodeToString(testData)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want result
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
args: args{
|
||||
uri: "data:application/x-bittorrent;base64," + testData64,
|
||||
},
|
||||
want: result{
|
||||
mime: "application/x-bittorrent",
|
||||
data: testData,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail-dirty-data",
|
||||
args: args{
|
||||
uri: "data::application/x-bittorrent;base64,!@$",
|
||||
},
|
||||
want: result{
|
||||
mime: "",
|
||||
data: nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail-miss-data",
|
||||
args: args{
|
||||
uri: ":application/x-bittorrent;base64," + testData64,
|
||||
},
|
||||
want: result{
|
||||
mime: "",
|
||||
data: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mime, data := ParseDataUri(tt.args.uri)
|
||||
got := result{
|
||||
mime: mime,
|
||||
data: data,
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("ParseDataUri() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,6 @@ subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"donate": "Donate",
|
||||
"exit": "Exit",
|
||||
"create": "Create Task",
|
||||
"advancedOptions": "Advanced Options",
|
||||
"downloadLink": "Download Link",
|
||||
"downloadLinkValid": "Please enter the download link",
|
||||
"downloadLinkHit": "Please enter the download link, HTTP/HTTPS/MAGNET supported@append",
|
||||
@@ -20,6 +21,7 @@
|
||||
"noFileSelected": "Please select at least one file to continue.",
|
||||
"noStoragePermission": "Storage permission required",
|
||||
"selectFile": "Select File",
|
||||
"rename": "Rename",
|
||||
"basic": "Basic",
|
||||
"advanced": "Advanced",
|
||||
"general": "General",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"donate": "打赏",
|
||||
"exit": "退出",
|
||||
"create": "创建任务",
|
||||
"advancedOptions": "高级选项",
|
||||
"downloadLink": "下载链接",
|
||||
"downloadLinkValid": "请输入下载链接",
|
||||
"downloadLinkHit": "请输入下载链接,支持 HTTP/HTTPS/MAGNET@append",
|
||||
@@ -20,6 +21,7 @@
|
||||
"noFileSelected": "请至少选择一个文件下载",
|
||||
"noStoragePermission": "需要开启存储权限",
|
||||
"selectFile": "选择文件",
|
||||
"rename": "重命名",
|
||||
"basic": "基础",
|
||||
"advanced": "高级",
|
||||
"general": "通用",
|
||||
|
||||
@@ -147,6 +147,8 @@ Future<Response<String>> proxyRequest<T>(String uri,
|
||||
options.headers!["X-Target-Uri"] = uri;
|
||||
|
||||
// add timestamp to avoid cache
|
||||
return _client.dio.request("/api/v1/proxy?t=${DateTime.now()}",
|
||||
data: data, options: options);
|
||||
return _client.dio.request(
|
||||
"/api/v1/proxy?t=${DateTime.now().millisecondsSinceEpoch}",
|
||||
data: data,
|
||||
options: options);
|
||||
}
|
||||
@@ -2,17 +2,18 @@ import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'options.g.dart';
|
||||
|
||||
@JsonSerializable(explicitToJson: true, genericArgumentFactories: true)
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class Options {
|
||||
String name;
|
||||
String path;
|
||||
List<int> selectFiles;
|
||||
Map<String, dynamic>? extra;
|
||||
Object? extra;
|
||||
|
||||
Options({
|
||||
required this.name,
|
||||
required this.path,
|
||||
required this.selectFiles,
|
||||
this.extra,
|
||||
});
|
||||
|
||||
factory Options.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -11,7 +11,8 @@ Options _$OptionsFromJson(Map<String, dynamic> json) => Options(
|
||||
path: json['path'] as String,
|
||||
selectFiles:
|
||||
(json['selectFiles'] as List<dynamic>).map((e) => e as int).toList(),
|
||||
)..extra = json['extra'] as Map<String, dynamic>?;
|
||||
extra: json['extra'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$OptionsToJson(Options instance) {
|
||||
final val = <String, dynamic>{
|
||||
|
||||
@@ -5,10 +5,11 @@ part 'request.g.dart';
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class Request {
|
||||
String url;
|
||||
Map<String, dynamic>? extra;
|
||||
Object? extra;
|
||||
|
||||
Request({
|
||||
required this.url,
|
||||
this.extra,
|
||||
});
|
||||
|
||||
factory Request.fromJson(Map<String, dynamic> json) =>
|
||||
@@ -20,7 +21,7 @@ class Request {
|
||||
@JsonSerializable()
|
||||
class ReqExtraHttp {
|
||||
String method = 'GET';
|
||||
Map<String, String> headers = {};
|
||||
Map<String, String> header = {};
|
||||
String body = '';
|
||||
|
||||
ReqExtraHttp();
|
||||
@@ -30,3 +31,15 @@ class ReqExtraHttp {
|
||||
|
||||
Map<String, dynamic> toJson() => _$ReqExtraHttpToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class ReqExtraBt {
|
||||
List<String> trackers = [];
|
||||
|
||||
ReqExtraBt();
|
||||
|
||||
factory ReqExtraBt.fromJson(Map<String, dynamic> json) =>
|
||||
_$ReqExtraBtFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$ReqExtraBtToJson(this);
|
||||
}
|
||||
@@ -8,7 +8,8 @@ part of 'request.dart';
|
||||
|
||||
Request _$RequestFromJson(Map<String, dynamic> json) => Request(
|
||||
url: json['url'] as String,
|
||||
)..extra = json['extra'] as Map<String, dynamic>?;
|
||||
extra: json['extra'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$RequestToJson(Request instance) {
|
||||
final val = <String, dynamic>{
|
||||
@@ -27,12 +28,21 @@ Map<String, dynamic> _$RequestToJson(Request instance) {
|
||||
|
||||
ReqExtraHttp _$ReqExtraHttpFromJson(Map<String, dynamic> json) => ReqExtraHttp()
|
||||
..method = json['method'] as String
|
||||
..headers = Map<String, String>.from(json['headers'] as Map)
|
||||
..header = Map<String, String>.from(json['header'] as Map)
|
||||
..body = json['body'] as String;
|
||||
|
||||
Map<String, dynamic> _$ReqExtraHttpToJson(ReqExtraHttp instance) =>
|
||||
<String, dynamic>{
|
||||
'method': instance.method,
|
||||
'headers': instance.headers,
|
||||
'header': instance.header,
|
||||
'body': instance.body,
|
||||
};
|
||||
|
||||
ReqExtraBt _$ReqExtraBtFromJson(Map<String, dynamic> json) => ReqExtraBt()
|
||||
..trackers =
|
||||
(json['trackers'] as List<dynamic>).map((e) => e as String).toList();
|
||||
|
||||
Map<String, dynamic> _$ReqExtraBtToJson(ReqExtraBt instance) =>
|
||||
<String, dynamic>{
|
||||
'trackers': instance.trackers,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:ui' as ui;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -42,10 +42,13 @@ const allTrackerSubscribeUrls = [
|
||||
'https://github.com/XIU2/TrackersListCollection/raw/master/http.txt',
|
||||
];
|
||||
const allTrackerCdns = [
|
||||
// jsdelivr: https://cdn.jsdelivr.net/gh/ngosang/trackerslist/trackers_all.txt
|
||||
["https://cdn.jsdelivr.net/gh", r".*github.com(/.*)/raw/master(/.*)"],
|
||||
// nuaa: https://hub.nuaa.cf/ngosang/trackerslist/raw/master/trackers_all.txt
|
||||
["https://hub.nuaa.cf", r".*github.com(/.*)"]
|
||||
// jsdelivr: https://fastly.jsdelivr.net/gh/ngosang/trackerslist/trackers_all.txt
|
||||
["https://fastly.jsdelivr.net/gh", r".*github.com(/.*)/raw/master(/.*)"],
|
||||
// ghproxy: https://ghproxy.com/https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt
|
||||
[
|
||||
"https://ghproxy.com/https://raw.githubusercontent.com",
|
||||
r".*github.com(/.*)/raw(/.*)"
|
||||
]
|
||||
];
|
||||
final allTrackerSubscribeUrlCdns = Map.fromIterable(allTrackerSubscribeUrls,
|
||||
key: (v) => v as String,
|
||||
@@ -265,18 +268,16 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
||||
final btExtConfig = downloaderConfig.value.extra.bt;
|
||||
final result = <String>[];
|
||||
for (var u in btExtConfig.trackerSubscribeUrls) {
|
||||
var allFail = true;
|
||||
for (var cdn in allTrackerSubscribeUrlCdns[u]!) {
|
||||
try {
|
||||
result.addAll(await _fetchTrackers(cdn));
|
||||
allFail = false;
|
||||
break;
|
||||
} catch (e) {
|
||||
logger.w("subscribe trackers fail, url: $cdn", e);
|
||||
}
|
||||
final cdns = allTrackerSubscribeUrlCdns[u];
|
||||
if (cdns == null) {
|
||||
continue;
|
||||
}
|
||||
if (allFail) {
|
||||
throw Exception('subscribe trackers fail, network error');
|
||||
try {
|
||||
final trackers =
|
||||
await Util.anyOk(cdns.map((cdn) => _fetchTrackers(cdn)));
|
||||
result.addAll(trackers);
|
||||
} catch (e) {
|
||||
logger.w("subscribe trackers fail, url: $u", e);
|
||||
}
|
||||
}
|
||||
btExtConfig.subscribeTrackers.clear();
|
||||
@@ -336,7 +337,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
|
||||
extra.themeMode = ThemeMode.system.name;
|
||||
}
|
||||
if (extra.locale.isEmpty) {
|
||||
final systemLocale = getLocaleKey(ui.window.locale);
|
||||
final systemLocale = getLocaleKey(PlatformDispatcher.instance.locale);
|
||||
extra.locale = AppTranslation.translations.containsKey(systemLocale)
|
||||
? systemLocale
|
||||
: getLocaleKey(fallbackLocale);
|
||||
|
||||
@@ -1,9 +1,38 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class CreateController extends GetxController {
|
||||
class CreateController extends GetxController
|
||||
with GetSingleTickerProviderStateMixin {
|
||||
// final files = [].obs;
|
||||
final RxList fileInfos = [].obs;
|
||||
final RxList selectedIndexes = [].obs;
|
||||
final RxList openedFolders = [].obs;
|
||||
final isResolving = false.obs;
|
||||
final showAdvanced = false.obs;
|
||||
late TabController advancedTabController;
|
||||
final fileDataUri = "".obs;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
advancedTabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
advancedTabController.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
void setFileDataUri(Uint8List bytes) {
|
||||
fileDataUri.value =
|
||||
"data:application/x-bittorrent;base64,${base64.encode(bytes)}";
|
||||
}
|
||||
|
||||
void clearFileDataUri() {
|
||||
fileDataUri.value = "";
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:autoscale_tabbarview/autoscale_tabbarview.dart';
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
import '../../../../api/model/resolve_result.dart';
|
||||
import 'package:rounded_loading_button/rounded_loading_button.dart';
|
||||
@@ -9,6 +11,7 @@ import '../../../../api/api.dart';
|
||||
import '../../../../api/model/create_task.dart';
|
||||
import '../../../../api/model/options.dart';
|
||||
import '../../../../api/model/request.dart';
|
||||
import '../../../../util/input_formatter.dart';
|
||||
import '../../../../util/message.dart';
|
||||
import '../../../../util/util.dart';
|
||||
import '../../../routes/app_pages.dart';
|
||||
@@ -22,6 +25,10 @@ class CreateView extends GetView<CreateController> {
|
||||
|
||||
final _urlController = TextEditingController();
|
||||
final _confirmController = RoundedLoadingButtonController();
|
||||
final _httpUaController = TextEditingController();
|
||||
final _httpCookieController = TextEditingController();
|
||||
final _httpRefererController = TextEditingController();
|
||||
final _btTrackerController = TextEditingController();
|
||||
|
||||
CreateView({Key? key}) : super(key: key);
|
||||
|
||||
@@ -44,39 +51,51 @@ class CreateView extends GetView<CreateController> {
|
||||
title: Text('create'.tr),
|
||||
),
|
||||
body: DropTarget(
|
||||
onDragDone: (details) {
|
||||
_urlController.text = details.files[0].path;
|
||||
onDragDone: (details) async {
|
||||
if (!Util.isWeb()) {
|
||||
_urlController.text = details.files[0].path;
|
||||
return;
|
||||
}
|
||||
_urlController.text = details.files[0].name;
|
||||
final bytes = await details.files[0].readAsBytes();
|
||||
controller.setFileDataUri(bytes);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
|
||||
child: Form(
|
||||
key: _resolveFormKey,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
|
||||
child: Form(
|
||||
key: _resolveFormKey,
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
child: Column(children: [
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
autofocus: true,
|
||||
controller: _urlController,
|
||||
minLines: 1,
|
||||
maxLines: 30,
|
||||
decoration: InputDecoration(
|
||||
hintText: _hitText(),
|
||||
hintStyle: const TextStyle(fontSize: 12),
|
||||
labelText: 'downloadLink'.tr,
|
||||
icon: const Icon(Icons.link),
|
||||
suffixIcon: IconButton(
|
||||
onPressed: _urlController.clear,
|
||||
icon: const Icon(Icons.clear),
|
||||
),
|
||||
autofocus: true,
|
||||
controller: _urlController,
|
||||
minLines: 1,
|
||||
maxLines: 5,
|
||||
decoration: InputDecoration(
|
||||
hintText: _hitText(),
|
||||
hintStyle: const TextStyle(fontSize: 12),
|
||||
labelText: 'downloadLink'.tr,
|
||||
icon: const Icon(Icons.link),
|
||||
suffixIcon: IconButton(
|
||||
onPressed: () {
|
||||
_urlController.clear();
|
||||
controller.clearFileDataUri();
|
||||
},
|
||||
icon: const Icon(Icons.clear),
|
||||
),
|
||||
validator: (v) {
|
||||
return v!.trim().isNotEmpty
|
||||
? null
|
||||
: 'downloadLinkValid'.tr;
|
||||
}),
|
||||
),
|
||||
validator: (v) {
|
||||
return v!.trim().isNotEmpty
|
||||
? null
|
||||
: 'downloadLinkValid'.tr;
|
||||
},
|
||||
onChanged: (v) async {
|
||||
controller.clearFileDataUri();
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.folder_open),
|
||||
@@ -85,31 +104,110 @@ class CreateView extends GetView<CreateController> {
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ["torrent"]);
|
||||
if (pr != null) {
|
||||
_urlController.text = pr.files[0].path ?? "";
|
||||
if (!Util.isWeb()) {
|
||||
_urlController.text = pr.files[0].path ?? "";
|
||||
return;
|
||||
}
|
||||
_urlController.text = pr.files[0].name;
|
||||
controller.setFileDataUri(pr.files[0].bytes!);
|
||||
}
|
||||
}),
|
||||
],
|
||||
),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(15.0),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
]
|
||||
//.where((e) => e != null).map((e) => e!).toList(),
|
||||
),
|
||||
Obx(() => Visibility(
|
||||
visible: controller.showAdvanced.value,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 40, top: 15),
|
||||
child: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
controller: controller.advancedTabController,
|
||||
tabs: const [
|
||||
Tab(
|
||||
text: 'HTTP',
|
||||
),
|
||||
Tab(
|
||||
text: 'BitTorrent',
|
||||
)
|
||||
],
|
||||
),
|
||||
AutoScaleTabBarView(
|
||||
controller: controller.advancedTabController,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _httpUaController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'User-Agent',
|
||||
)),
|
||||
TextFormField(
|
||||
controller: _httpCookieController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Cookie',
|
||||
)),
|
||||
TextFormField(
|
||||
controller: _httpRefererController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Referer',
|
||||
)),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _btTrackerController,
|
||||
maxLines: 5,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Trakers',
|
||||
hintText: 'addTrackerHit'.tr,
|
||||
)),
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
))),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 15),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
controller.showAdvanced.value =
|
||||
!controller.showAdvanced.value;
|
||||
},
|
||||
child:
|
||||
Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Obx(() => Checkbox(
|
||||
value: controller.showAdvanced.value,
|
||||
onChanged: (bool? value) {
|
||||
controller.showAdvanced.value =
|
||||
value ?? false;
|
||||
},
|
||||
)),
|
||||
Text('advancedOptions'.tr),
|
||||
]),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 150,
|
||||
height: 40,
|
||||
child: RoundedLoadingButton(
|
||||
color: Get.theme.colorScheme.secondary,
|
||||
onPressed: _doResolve,
|
||||
controller: _confirmController,
|
||||
child: Text('confirm'.tr),
|
||||
),
|
||||
),
|
||||
child: RoundedLoadingButton(
|
||||
color: Get.theme.colorScheme.secondary,
|
||||
onPressed: _doResolve,
|
||||
controller: _confirmController,
|
||||
child: Text('confirm'.tr),
|
||||
),
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
]))),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -122,8 +220,27 @@ class CreateView extends GetView<CreateController> {
|
||||
try {
|
||||
_confirmController.start();
|
||||
if (_resolveFormKey.currentState!.validate()) {
|
||||
Object? extra;
|
||||
final submitUrl = Util.isWeb() && controller.fileDataUri.isNotEmpty
|
||||
? controller.fileDataUri.value
|
||||
: _urlController.text;
|
||||
if (controller.showAdvanced.value) {
|
||||
final u = Uri.parse(submitUrl);
|
||||
if (u.scheme.startsWith("http")) {
|
||||
extra = ReqExtraHttp()
|
||||
..header = {
|
||||
"User-Agent": _httpUaController.text,
|
||||
"Cookie": _httpCookieController.text,
|
||||
"Referer": _httpRefererController.text,
|
||||
};
|
||||
} else {
|
||||
extra = ReqExtraBt()
|
||||
..trackers = Util.textToLines(_btTrackerController.text);
|
||||
}
|
||||
}
|
||||
final rr = await resolve(Request(
|
||||
url: _urlController.text,
|
||||
url: submitUrl,
|
||||
extra: extra,
|
||||
));
|
||||
await _showResolveDialog(rr);
|
||||
}
|
||||
@@ -137,7 +254,8 @@ class CreateView extends GetView<CreateController> {
|
||||
|
||||
String _hitText() {
|
||||
return 'downloadLinkHit'.trParams({
|
||||
'append': Util.isDesktop() ? 'downloadLinkHitDesktop'.tr : '',
|
||||
'append':
|
||||
Util.isDesktop() || Util.isWeb() ? 'downloadLinkHitDesktop'.tr : '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,6 +264,8 @@ class CreateView extends GetView<CreateController> {
|
||||
final appController = Get.find<AppController>();
|
||||
|
||||
final createFormKey = GlobalKey<FormState>();
|
||||
final nameController = TextEditingController();
|
||||
final connectionsController = TextEditingController();
|
||||
final pathController = TextEditingController(
|
||||
text: appController.downloaderConfig.value.downloadDir);
|
||||
final downloadController = RoundedLoadingButtonController();
|
||||
@@ -168,6 +288,23 @@ class CreateView extends GetView<CreateController> {
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(child: FileListView(files: files)),
|
||||
TextFormField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'rename'.tr,
|
||||
),
|
||||
),
|
||||
TextFormField(
|
||||
controller: connectionsController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'connections'.tr,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
NumericalRangeFormatter(min: 1, max: 256),
|
||||
],
|
||||
),
|
||||
DirectorySelector(
|
||||
controller: pathController,
|
||||
),
|
||||
@@ -209,20 +346,18 @@ class CreateView extends GetView<CreateController> {
|
||||
return;
|
||||
}
|
||||
if (createFormKey.currentState!.validate()) {
|
||||
// if (Util.isAndroid()) {
|
||||
// if (!await Permission.storage.request().isGranted) {
|
||||
// Get.snackbar('error'.tr,
|
||||
// 'noStoragePermission'.tr);
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
await createTask(CreateTask(
|
||||
rid: rr.id,
|
||||
opts: Options(
|
||||
name: '',
|
||||
name: nameController.text,
|
||||
path: pathController.text,
|
||||
selectFiles: controller.selectedIndexes
|
||||
.cast<int>())));
|
||||
selectFiles:
|
||||
controller.selectedIndexes.cast<int>(),
|
||||
extra: connectionsController.text.isEmpty
|
||||
? null
|
||||
: (OptsExtraHttp()
|
||||
..connections = int.parse(
|
||||
connectionsController.text)))));
|
||||
Get.back();
|
||||
Get.rootDelegate.offNamed(Routes.TASK);
|
||||
}
|
||||
|
||||
@@ -28,17 +28,21 @@ class SettingController extends GetxController {
|
||||
void fetchLatestVersion() async {
|
||||
String? releaseDataStr;
|
||||
try {
|
||||
releaseDataStr =
|
||||
(await proxyRequest("https://gopeed.com/api/release")).data;
|
||||
} catch (e) {
|
||||
releaseDataStr = (await proxyRequest(
|
||||
"https://api.github.com/repos/GopeedLab/gopeed/releases/latest"))
|
||||
.data;
|
||||
} catch (e) {
|
||||
releaseDataStr =
|
||||
(await proxyRequest("https://gopeed.com/api/release")).data;
|
||||
}
|
||||
if (releaseDataStr == null) {
|
||||
return;
|
||||
}
|
||||
final releaseData = jsonDecode(releaseDataStr);
|
||||
final tagName = releaseData["tag_name"];
|
||||
if (tagName == null) {
|
||||
return;
|
||||
}
|
||||
latestVersion.value = releaseData["tag_name"].substring(1);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import 'package:intl/intl.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../../generated/locales.g.dart';
|
||||
import '../../../../util/input_formatter.dart';
|
||||
import '../../../../util/locale_manager.dart';
|
||||
import '../../../../util/message.dart';
|
||||
import '../../../../util/package_info.dart';
|
||||
@@ -89,7 +90,7 @@ class SettingView extends GetView<SettingController> {
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
_NumericalRangeFormatter(min: 1, max: 256),
|
||||
NumericalRangeFormatter(min: 1, max: 256),
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -116,7 +117,7 @@ class SettingView extends GetView<SettingController> {
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
_NumericalRangeFormatter(min: 1, max: 256),
|
||||
NumericalRangeFormatter(min: 1, max: 256),
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -187,7 +188,6 @@ class SettingView extends GetView<SettingController> {
|
||||
(Key key) {
|
||||
final trackersController = TextEditingController(
|
||||
text: btExtConfig.customTrackers.join('\r\n').toString());
|
||||
const ls = LineSplitter();
|
||||
return TextField(
|
||||
key: key,
|
||||
focusNode: FocusNode(),
|
||||
@@ -198,7 +198,7 @@ class SettingView extends GetView<SettingController> {
|
||||
hintText: 'addTrackerHit'.tr,
|
||||
),
|
||||
onChanged: (value) async {
|
||||
btExtConfig.customTrackers = ls.convert(value);
|
||||
btExtConfig.customTrackers = Util.textToLines(value);
|
||||
appController.refreshTrackers();
|
||||
|
||||
await debounceSave();
|
||||
@@ -408,7 +408,7 @@ class SettingView extends GetView<SettingController> {
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
_NumericalRangeFormatter(min: 0, max: 65535),
|
||||
NumericalRangeFormatter(min: 0, max: 65535),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -605,39 +605,3 @@ class SettingView extends GetView<SettingController> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// class _ConfigItem {
|
||||
// late String label;
|
||||
// late String Function() text;
|
||||
// late Widget Function(Key key) inputItem;
|
||||
//
|
||||
// _ConfigItem(this.label, this.text, this.inputItem);
|
||||
// }
|
||||
|
||||
class _NumericalRangeFormatter extends TextInputFormatter {
|
||||
final int min;
|
||||
final int max;
|
||||
|
||||
_NumericalRangeFormatter({required this.min, required this.max});
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
if (newValue.text.isEmpty) {
|
||||
return newValue;
|
||||
}
|
||||
var intVal = int.tryParse(newValue.text);
|
||||
if (intVal == null) {
|
||||
return oldValue;
|
||||
}
|
||||
if (intVal < min) {
|
||||
return newValue.copyWith(text: min.toString());
|
||||
} else if (intVal > max) {
|
||||
return oldValue.copyWith(text: max.toString());
|
||||
} else {
|
||||
return newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:gopeed/api/model/meta.dart';
|
||||
import 'package:gopeed/api/model/resource.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
import '../../api/api.dart';
|
||||
@@ -190,7 +191,7 @@ class BuildTaskListView extends GetView {
|
||||
: const SizedBox.shrink(),
|
||||
ListTile(
|
||||
// isThreeLine: true,
|
||||
title: Text(task.meta.res.name,
|
||||
title: Text(fileName(task.meta),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Get.textTheme.titleSmall),
|
||||
@@ -199,9 +200,9 @@ class BuildTaskListView extends GetView {
|
||||
style: context.textTheme.bodyLarge
|
||||
?.copyWith(color: Get.theme.disabledColor),
|
||||
),
|
||||
leading: task.meta.res.name.lastIndexOf('.') == -1
|
||||
? const Icon(FaIcons.file)
|
||||
: Icon(FaIcons.allIcons[findIcon(task.meta.res.name)]),
|
||||
leading: (task.meta.res.rootDir.isNotEmpty
|
||||
? const Icon(FaIcons.folder)
|
||||
: Icon(FaIcons.allIcons[findIcon(fileName(task.meta))])),
|
||||
|
||||
trailing: SizedBox(
|
||||
width: 180,
|
||||
@@ -220,4 +221,11 @@ class BuildTaskListView extends GetView {
|
||||
])
|
||||
]));
|
||||
}
|
||||
|
||||
String fileName(Meta meta) {
|
||||
if (meta.res.files.length > 1) {
|
||||
return meta.res.name;
|
||||
}
|
||||
return meta.opts.name.isEmpty ? meta.res.files[0].name : meta.opts.name;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ LibgopeedBoot create() => LibgopeedBootNative();
|
||||
|
||||
class LibgopeedBootNative implements LibgopeedBoot {
|
||||
late LibgopeedInterface _libgopeed;
|
||||
late SendPort _childSendPort;
|
||||
|
||||
LibgopeedBootNative() {
|
||||
if (!Util.isDesktop()) {
|
||||
@@ -44,11 +43,7 @@ class LibgopeedBootNative implements LibgopeedBoot {
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
if (Util.isDesktop()) {
|
||||
await _ffiStop();
|
||||
} else {
|
||||
await _libgopeed.stop();
|
||||
}
|
||||
await _libgopeed.stop();
|
||||
}
|
||||
|
||||
_ffiInit() {
|
||||
@@ -67,90 +62,9 @@ class LibgopeedBootNative implements LibgopeedBoot {
|
||||
|
||||
// FFI run in isolate
|
||||
Future<int> _ffiStart(StartConfig cfg) async {
|
||||
var startCompleter = Completer<int>();
|
||||
|
||||
var mainReceive = ReceivePort();
|
||||
var isolate = await Isolate.spawn((SendPort sendPort) {
|
||||
return await Isolate.run(() async {
|
||||
_ffiInit();
|
||||
var childReceive = ReceivePort();
|
||||
sendPort.send(childReceive.sendPort);
|
||||
|
||||
childReceive.listen((message) async {
|
||||
late _Result result;
|
||||
try {
|
||||
if (message is _StartParam) {
|
||||
result = _StartResult();
|
||||
(result as _StartResult).port = await _libgopeed.start(message.cfg);
|
||||
}
|
||||
if (message is _StopParam) {
|
||||
result = _StopResult();
|
||||
await _libgopeed.stop();
|
||||
childReceive.close();
|
||||
Isolate.exit(sendPort);
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
result.exception = e;
|
||||
}
|
||||
sendPort.send(result);
|
||||
});
|
||||
}, mainReceive.sendPort);
|
||||
|
||||
var childCompleter = Completer<void>();
|
||||
mainReceive.listen((message) {
|
||||
if (message is SendPort) {
|
||||
_childSendPort = message;
|
||||
childCompleter.complete();
|
||||
}
|
||||
if (message is _StartResult) {
|
||||
if (message.exception != null) {
|
||||
startCompleter.completeError(message.exception!);
|
||||
} else {
|
||||
startCompleter.complete(message.port!);
|
||||
}
|
||||
}
|
||||
if (message is _StopResult) {
|
||||
isolate.kill();
|
||||
if (message.exception != null) {
|
||||
_stopCompleter.completeError(message.exception!);
|
||||
} else {
|
||||
_stopCompleter.complete();
|
||||
}
|
||||
}
|
||||
return await _libgopeed.start(cfg);
|
||||
});
|
||||
await childCompleter.future;
|
||||
_childSendPort.send(_StartParam(cfg: cfg));
|
||||
return startCompleter.future;
|
||||
}
|
||||
|
||||
late Completer<void> _stopCompleter;
|
||||
|
||||
Future<void> _ffiStop() async {
|
||||
_stopCompleter = Completer<void>();
|
||||
_childSendPort.send(_StopParam());
|
||||
return _stopCompleter.future;
|
||||
}
|
||||
}
|
||||
|
||||
class _Result {
|
||||
Exception? exception;
|
||||
|
||||
_Result();
|
||||
}
|
||||
|
||||
class _StartParam {
|
||||
final StartConfig cfg;
|
||||
|
||||
_StartParam({required this.cfg});
|
||||
}
|
||||
|
||||
class _StartResult extends _Result {
|
||||
int? port;
|
||||
|
||||
_StartResult();
|
||||
}
|
||||
|
||||
class _StopParam {}
|
||||
|
||||
class _StopResult extends _Result {
|
||||
_StopResult();
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -27,6 +27,7 @@ class LocaleKeys {
|
||||
static const donate = 'donate';
|
||||
static const exit = 'exit';
|
||||
static const create = 'create';
|
||||
static const advancedOptions = 'advancedOptions';
|
||||
static const downloadLink = 'downloadLink';
|
||||
static const downloadLinkValid = 'downloadLinkValid';
|
||||
static const downloadLinkHit = 'downloadLinkHit';
|
||||
@@ -35,6 +36,7 @@ class LocaleKeys {
|
||||
static const noFileSelected = 'noFileSelected';
|
||||
static const noStoragePermission = 'noStoragePermission';
|
||||
static const selectFile = 'selectFile';
|
||||
static const rename = 'rename';
|
||||
static const basic = 'basic';
|
||||
static const advanced = 'advanced';
|
||||
static const general = 'general';
|
||||
@@ -90,6 +92,7 @@ class Locales {
|
||||
'donate': 'Donate',
|
||||
'exit': 'Exit',
|
||||
'create': 'Create Task',
|
||||
'advancedOptions': 'Advanced Options',
|
||||
'downloadLink': 'Download Link',
|
||||
'downloadLinkValid': 'Please enter the download link',
|
||||
'downloadLinkHit':
|
||||
@@ -99,6 +102,7 @@ class Locales {
|
||||
'noFileSelected': 'Please select at least one file to continue.',
|
||||
'noStoragePermission': 'Storage permission required',
|
||||
'selectFile': 'Select File',
|
||||
'rename': 'Rename',
|
||||
'basic': 'Basic',
|
||||
'advanced': 'Advanced',
|
||||
'general': 'General',
|
||||
@@ -336,6 +340,7 @@ class Locales {
|
||||
'donate': '打赏',
|
||||
'exit': '退出',
|
||||
'create': '创建任务',
|
||||
'advancedOptions': '高级选项',
|
||||
'downloadLink': '下载链接',
|
||||
'downloadLinkValid': '请输入下载链接',
|
||||
'downloadLinkHit': '请输入下载链接,支持 HTTP/HTTPS/MAGNET@append',
|
||||
@@ -344,6 +349,7 @@ class Locales {
|
||||
'noFileSelected': '请至少选择一个文件下载',
|
||||
'noStoragePermission': '需要开启存储权限',
|
||||
'selectFile': '选择文件',
|
||||
'rename': '重命名',
|
||||
'basic': '基础',
|
||||
'advanced': '高级',
|
||||
'general': '通用',
|
||||
|
||||
@@ -26,7 +26,7 @@ Future<void> init() async {
|
||||
if (Util.isDesktop()) {
|
||||
await windowManager.ensureInitialized();
|
||||
const windowOptions = WindowOptions(
|
||||
size: Size(1024, 768),
|
||||
size: Size(800, 600),
|
||||
center: true,
|
||||
skipTaskbar: false,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
findIcon(String filename) {
|
||||
String res = 'doc';
|
||||
String res = 'file';
|
||||
String ext = filename.substring(filename.lastIndexOf('.') + 1);
|
||||
for (var iconMap in iconMaps) {
|
||||
if (iconMap['extensions'].contains(ext)) {
|
||||
|
||||
@@ -28,6 +28,7 @@ class FaIcons {
|
||||
|
||||
static const _kFontFam = 'FontAwesome';
|
||||
static const Map<String, IconData> allIcons = {
|
||||
'file': file,
|
||||
'doc': doc,
|
||||
'file_image': file_image,
|
||||
'file_video': file_video,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// A [TextInputFormatter] that restricts input to a numerical range between [min] and [max].
|
||||
class NumericalRangeFormatter extends TextInputFormatter {
|
||||
final int min;
|
||||
final int max;
|
||||
|
||||
NumericalRangeFormatter({required this.min, required this.max});
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
if (newValue.text.isEmpty) {
|
||||
return newValue;
|
||||
}
|
||||
var intVal = int.tryParse(newValue.text);
|
||||
if (intVal == null) {
|
||||
return oldValue;
|
||||
}
|
||||
if (intVal < min) {
|
||||
return newValue.copyWith(text: min.toString());
|
||||
} else if (intVal > max) {
|
||||
return oldValue.copyWith(text: max.toString());
|
||||
} else {
|
||||
return newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -11,7 +13,9 @@ class Util {
|
||||
}
|
||||
|
||||
static String fmtByte(int byte) {
|
||||
if (byte < 1024) {
|
||||
if (byte < 0) {
|
||||
return "0 B";
|
||||
} else if (byte < 1024) {
|
||||
return "$byte B";
|
||||
} else if (byte < 1024 * 1024) {
|
||||
return "${(byte / 1024).toStringAsFixed(2)} KB";
|
||||
@@ -59,4 +63,33 @@ class Util {
|
||||
static isWeb() {
|
||||
return kIsWeb;
|
||||
}
|
||||
|
||||
static List<String> textToLines(String text) {
|
||||
if (text.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
const ls = LineSplitter();
|
||||
return ls.convert(text);
|
||||
}
|
||||
|
||||
// if one future complete, return the result, only all future error, return the last error
|
||||
static anyOk<T>(Iterable<Future<T>> futures) {
|
||||
final completer = Completer<T>();
|
||||
Object lastError;
|
||||
var count = futures.length;
|
||||
for (var future in futures) {
|
||||
future.then((value) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(value);
|
||||
}
|
||||
}).catchError((e) {
|
||||
lastError = e;
|
||||
count--;
|
||||
if (count == 0) {
|
||||
completer.completeError(lastError);
|
||||
}
|
||||
});
|
||||
}
|
||||
return completer.future;
|
||||
}
|
||||
}
|
||||
+229
-214
File diff suppressed because it is too large.
Load diff
+9
-10
@@ -15,10 +15,10 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
version: 1.3.5+1
|
||||
version: 1.3.10+1
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.6 <3.0.0"
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
# Dependencies specify other packages that your package needs in order to work.
|
||||
# To automatically upgrade your package dependencies to the latest versions
|
||||
@@ -42,13 +42,13 @@ dependencies:
|
||||
json_annotation: ^4.6.0
|
||||
dio: ^5.2.1
|
||||
path_provider: ^2.0.11
|
||||
file_picker: ^5.2.5
|
||||
file_picker: 5.3.1
|
||||
macos_secure_bookmarks: ^0.2.1
|
||||
rounded_loading_button: ^2.1.0
|
||||
url_launcher: ^6.1.6
|
||||
shared_preferences: ^2.0.15
|
||||
shared_preferences: ^2.2.0
|
||||
logger: ^1.3.0
|
||||
desktop_drop: ^0.3.3
|
||||
desktop_drop: ^0.4.3
|
||||
package_info_plus: ^4.0.0
|
||||
fluent_ui: ^4.4.0
|
||||
path: any
|
||||
@@ -57,6 +57,7 @@ dependencies:
|
||||
uri_to_file: ^0.2.0
|
||||
window_manager: ^0.3.4
|
||||
tray_manager: ^0.2.0
|
||||
autoscale_tabbarview: ^1.0.2
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
@@ -67,14 +68,11 @@ dev_dependencies:
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^2.0.0
|
||||
ffigen: ^6.1.2
|
||||
ffigen: ^8.0.2
|
||||
# build json model: flutter pub run build_runner build --delete-conflicting-outputs
|
||||
build_runner: ^2.2.1
|
||||
json_serializable: ^6.3.2
|
||||
flutter_launcher_icons:
|
||||
git:
|
||||
url: https://github.com/fluttercommunity/flutter_launcher_icons.git
|
||||
ref: v0.11.0
|
||||
flutter_launcher_icons: ^0.13.1
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
@@ -128,6 +126,7 @@ flutter_icons:
|
||||
generate: true
|
||||
image_path: "assets/icon/icon_macos_1024.png"
|
||||
|
||||
# flutter pub run ffigen
|
||||
ffigen:
|
||||
name: LibgopeedBind
|
||||
description: Bindings to gopeed library.
|
||||
|
||||
Reference in new issue
Block a user