Compare commits

..
11 Commits
52 changed files with 3311 additions and 170 deletions

No files matched your search

+37 -10
View File
@@ -198,7 +198,7 @@ jobs:
name: Build Amd64 Libraries name: Build Amd64 Libraries
run: | run: |
mkdir -p ui/flutter/macos/Frameworks mkdir -p ui/flutter/macos/Frameworks
cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/macos/Frameworks/libgopeed.dylib cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/macos/Frameworks/libgopeed.dylib
cp ui/flutter/lib-amd64/host ui/flutter/assets/exec/host cp ui/flutter/lib-amd64/host ui/flutter/assets/exec/host
cp ui/flutter/lib-amd64/updater ui/flutter/assets/exec/updater cp ui/flutter/lib-amd64/updater ui/flutter/assets/exec/updater
@@ -206,17 +206,17 @@ jobs:
name: Build Universal Libraries name: Build Universal Libraries
run: | run: |
mkdir -p ui/flutter/macos/Frameworks mkdir -p ui/flutter/macos/Frameworks
# arm64 lib # arm64 lib
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/.temp/libgopeed.dylib.arm64 github.com/GopeedLab/gopeed/bind/desktop go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/.temp/libgopeed.dylib.arm64 github.com/GopeedLab/gopeed/bind/desktop
go build -ldflags="-w -s" -o ui/flutter/.temp/host.arm64 github.com/GopeedLab/gopeed/cmd/host go build -ldflags="-w -s" -o ui/flutter/.temp/host.arm64 github.com/GopeedLab/gopeed/cmd/host
go build -ldflags="-w -s" -o ui/flutter/.temp/updater.arm64 github.com/GopeedLab/gopeed/cmd/updater go build -ldflags="-w -s" -o ui/flutter/.temp/updater.arm64 github.com/GopeedLab/gopeed/cmd/updater
# amd64 lib # amd64 lib
cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.amd64 cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.amd64
cp ui/flutter/lib-amd64/host ui/flutter/.temp/host.amd64 cp ui/flutter/lib-amd64/host ui/flutter/.temp/host.amd64
cp ui/flutter/lib-amd64/updater ui/flutter/.temp/updater.amd64 cp ui/flutter/lib-amd64/updater ui/flutter/.temp/updater.amd64
# universal lib # universal lib
lipo -create -output ui/flutter/macos/Frameworks/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.arm64 ui/flutter/.temp/libgopeed.dylib.amd64 lipo -create -output ui/flutter/macos/Frameworks/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.arm64 ui/flutter/.temp/libgopeed.dylib.amd64
lipo -create -output ui/flutter/assets/exec/host ui/flutter/.temp/host.arm64 ui/flutter/.temp/host.amd64 lipo -create -output ui/flutter/assets/exec/host ui/flutter/.temp/host.arm64 ui/flutter/.temp/host.amd64
@@ -240,7 +240,7 @@ jobs:
EOF EOF
mkdir dist mkdir dist
appdmg appdmg.json dist/Gopeed-v$VERSION-macos.dmg appdmg appdmg.json dist/Gopeed-v$VERSION-macos.dmg
if [[ "${{ matrix.channel }}" == "arm64" ]]; then if [[ "${{ matrix.channel }}" == "arm64" ]]; then
mv dist/Gopeed-v$VERSION-macos.dmg dist/Gopeed-v$VERSION-macos-arm64.dmg mv dist/Gopeed-v$VERSION-macos.dmg dist/Gopeed-v$VERSION-macos-arm64.dmg
fi fi
@@ -271,10 +271,7 @@ jobs:
channel: master channel: master
- run: | - run: |
sudo apt update -y sudo apt update -y
# flutter build dependencies sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate imagemagick potrace
sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev
# appimage build dependencies
sudo apt install -y libfuse2 locate
arch=$(uname -m) arch=$(uname -m)
wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage" wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
chmod +x appimagetool chmod +x appimagetool
@@ -288,6 +285,29 @@ jobs:
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater
cd ui/flutter cd ui/flutter
ICON_PATH=$(find assets -name "logo.png" -o -name "icon.png" | head -n 1)
[ -z "$ICON_PATH" ] && ICON_PATH="assets/icon.png" && touch assets/icon.png
convert "$ICON_PATH" assets/icon.svg
ICON_PATH="assets/icon.svg"
RPM_ARCH=$(uname -m)
mkdir -p linux/packaging/rpm
cat << EOF > linux/packaging/rpm/make_config.yaml
display_name: Gopeed
icon: $ICON_PATH
summary: High speed downloader.
description: A high speed downloader that supports all platforms.
group: Applications/Internet
license: GPL-3.0
url: https://github.com/GopeedLab/gopeed
vendor: GopeedLab
maintainer: GopeedLab <[email protected]>
build_arch: $RPM_ARCH
EOF
cat << EOF > distribute_options.yaml cat << EOF > distribute_options.yaml
output: dist/ output: dist/
releases: releases:
@@ -307,12 +327,18 @@ jobs:
build_args: build_args:
dart-define: dart-define:
UPDATE_CHANNEL: linuxDeb UPDATE_CHANNEL: linuxDeb
- name: rpm
package:
platform: linux
target: rpm
build_args:
dart-define:
UPDATE_CHANNEL: linuxRpm
EOF EOF
dart pub global activate -sgit https://github.com/GopeedLab/flutter_distributor.git --git-path packages/flutter_distributor dart pub global activate -sgit https://github.com/GopeedLab/flutter_distributor.git --git-path packages/flutter_distributor
flutter_distributor release --name linux flutter_distributor release --name linux
cd dist/* cd dist/*
# Get current running architecture, x86_64 -> amd64 and aarch64 -> arm64
ARCH="amd64" ARCH="amd64"
if [[ "${{ matrix.os }}" == *-arm ]]; then if [[ "${{ matrix.os }}" == *-arm ]]; then
ARCH="arm64" ARCH="arm64"
@@ -320,6 +346,7 @@ jobs:
mv gopeed-*-linux.AppImage Gopeed-v${VERSION}-linux-${ARCH}.AppImage mv gopeed-*-linux.AppImage Gopeed-v${VERSION}-linux-${ARCH}.AppImage
mv gopeed-*-linux.deb Gopeed-v${VERSION}-linux-${ARCH}.deb mv gopeed-*-linux.deb Gopeed-v${VERSION}-linux-${ARCH}.deb
mv gopeed-*-linux.rpm Gopeed-v${VERSION}-linux-${ARCH}.rpm
- name: Upload - name: Upload
uses: shogo82148/actions-upload-release-asset@v1 uses: shogo82148/actions-upload-release-asset@v1
with: with:
+1 -1
View File
@@ -69,7 +69,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
matrix: matrix:
os: [windows-latest, macos-latest, ubuntu-22.04, ubuntu-22.04-arm] os: [windows-2022, macos-latest, ubuntu-22.04, ubuntu-22.04-arm]
needs: [test] needs: [test]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
+7
View File
@@ -5,6 +5,7 @@
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases) [![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases) [![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://docs.gopeed.com/donate.html) [![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://docs.gopeed.com/donate.html)
[![WeChat](https://img.shields.io/badge/WeChat%20Official%20Account-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB) [![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -170,6 +171,12 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
Gopeed also provides a browser extension to take over browser downloads, supporting browsers such as Chrome, Edge, Firefox, etc., please refer to: [https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension) Gopeed also provides a browser extension to take over browser downloads, supporting browsers such as Chrome, Edge, Firefox, etc., please refer to: [https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
## 📱 WeChat Official Account
Follow our WeChat Official Account to get the latest updates and news.
<img src="_docs/img/weixin.png" width="200" />
## 💝 Donate ## 💝 Donate
If you like this project, please consider [donating](https://docs.gopeed.com/donate.html) to support the development of this project, thank you! If you like this project, please consider [donating](https://docs.gopeed.com/donate.html) to support the development of this project, thank you!
+7
View File
@@ -5,6 +5,7 @@
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases) [![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases) [![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://docs.gopeed.com/donate.html) [![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://docs.gopeed.com/donate.html)
[![WeChat](https://img.shields.io/badge/WeChat%20Official%20Account-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB) [![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -159,6 +160,12 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
### 🛠️ コマンドツール ### 🛠️ コマンドツール
## 📱 WeChat 公式アカウント
公式アカウントをフォローして、最新のアップデートやニュースを入手してください。
<img src="_docs/img/weixin.png" width="200" />
## 💝 寄付 ## 💝 寄付
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://docs.gopeed.com/donate.html)をご検討ください! もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://docs.gopeed.com/donate.html)をご検討ください!
+7
View File
@@ -5,6 +5,7 @@
[![Phiên bản](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases) [![Phiên bản](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Tải về](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases) [![Tải về](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Ủng hộ](https://img.shields.io/badge/%24-ủng%20hộ-ff69b4.svg)](https://docs.gopeed.com/donate.html) [![Ủng hộ](https://img.shields.io/badge/%24-ủng%20hộ-ff69b4.svg)](https://docs.gopeed.com/donate.html)
[![WeChat](https://img.shields.io/badge/WeChat%20Official%20Account-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB) [![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -165,6 +166,12 @@ Sử dụng `go install`:
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
``` ```
## 📱 WeChat Official Account
Theo dõi tài khoản chính thức để nhận các cập nhật và tin tức mới nhất.
<img src="_docs/img/weixin.png" width="200" />
## 💝 Quyên góp ## 💝 Quyên góp
Nếu bạn thích dự án này, xin vui lòng xem xét [quyên góp](https://docs.gopeed.com/donate.html) để hỗ trợ phát triển dự án này, cảm ơn bạn! Nếu bạn thích dự án này, xin vui lòng xem xét [quyên góp](https://docs.gopeed.com/donate.html) để hỗ trợ phát triển dự án này, cảm ơn bạn!
+7
View File
@@ -5,6 +5,7 @@
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases) [![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases) [![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://docs.gopeed.com/zh/donate.html) [![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://docs.gopeed.com/zh/donate.html)
[![WeChat](https://img.shields.io/badge/%E5%BE%AE%E4%BF%A1%E5%85%AC%E4%BC%97%E5%8F%B7-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB) [![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -169,6 +170,12 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、Edge、Firefox 等浏览器,具体请参考:[https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension) Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、Edge、Firefox 等浏览器,具体请参考:[https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
## 📱 微信公众号
关注公众号获取项目最新动态和资讯。
<img src="_docs/img/weixin.png" width="200" />
## 💝 赞助 ## 💝 赞助
如果觉得项目对你有帮助,请考虑[赞助](https://docs.gopeed.com/zh/donate)以支持这个项目的发展,非常感谢! 如果觉得项目对你有帮助,请考虑[赞助](https://docs.gopeed.com/zh/donate)以支持这个项目的发展,非常感谢!
+7
View File
@@ -5,6 +5,7 @@
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases) [![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases) [![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://docs.gopeed.com/donate.html) [![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://docs.gopeed.com/donate.html)
[![WeChat](https://img.shields.io/badge/%E5%BE%AE%E4%BF%A1%E5%85%AC%E4%BC%97%E5%8F%B7-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB) [![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -166,6 +167,12 @@ Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
``` ```
## 📱 微信公眾號
關注公眾號獲取項目最新動態和資訊。
<img src="_docs/img/weixin.png" width="200" />
## 💝 贊助 ## 💝 贊助
如果你認為該項目對你有所幫助,請考慮[贊助](https://docs.gopeed.com/zh/donate)以支持該項目的持續發展,謝謝! 如果你認為該項目對你有所幫助,請考慮[贊助](https://docs.gopeed.com/zh/donate)以支持該項目的持續發展,謝謝!
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+3 -10
View File
@@ -27,29 +27,23 @@ require (
require ( require (
github.com/andybalholm/brotli v1.1.1 // indirect github.com/andybalholm/brotli v1.1.1 // indirect
github.com/ebitengine/purego v0.8.1 // indirect
github.com/go-ole/go-ole v1.2.6 // 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/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/icholy/digest v1.1.0 // indirect github.com/icholy/digest v1.1.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c // indirect
github.com/onsi/ginkgo/v2 v2.23.4 // indirect github.com/onsi/ginkgo/v2 v2.23.4 // indirect
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c // indirect
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.51.0 // indirect github.com/quic-go/quic-go v0.51.0 // indirect
github.com/refraction-networking/utls v1.6.7 // indirect github.com/refraction-networking/utls v1.6.7 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/stretchr/testify v1.10.0 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/mock v0.5.1 // indirect go.uber.org/mock v0.5.1 // indirect
) )
require ( require (
dario.cat/mergo v1.0.0 // indirect dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect github.com/Microsoft/go-winio v0.6.1
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect
github.com/RoaringBitmap/roaring v1.9.4 // indirect github.com/RoaringBitmap/roaring v1.9.4 // indirect
github.com/acomagu/bufpipe v1.0.4 // indirect github.com/acomagu/bufpipe v1.0.4 // indirect
@@ -126,7 +120,6 @@ require (
github.com/protolambda/ctxlock v0.1.0 // indirect github.com/protolambda/ctxlock v0.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sergi/go-diff v1.1.0 // indirect github.com/sergi/go-diff v1.1.0 // indirect
github.com/shirou/gopsutil/v4 v4.24.12
github.com/skeema/knownhosts v1.2.0 // indirect github.com/skeema/knownhosts v1.2.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/tidwall/btree v1.7.0 // indirect github.com/tidwall/btree v1.7.0 // indirect
@@ -139,7 +132,7 @@ require (
golang.org/x/mod v0.24.0 // indirect golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.39.0 // indirect golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.32.0 // indirect golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect golang.org/x/text v0.24.0
golang.org/x/time v0.8.0 // indirect golang.org/x/time v0.8.0 // indirect
golang.org/x/tools v0.32.0 // indirect golang.org/x/tools v0.32.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
-20
View File
@@ -146,8 +146,6 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84= github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=
github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q= github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819 h1:RIB4cRk+lBqKK3Oy0r2gRX4ui7tuhiZq2SuTtTCi0/0= github.com/elazarl/goproxy v0.0.0-20221015165544-a0805db90819 h1:RIB4cRk+lBqKK3Oy0r2gRX4ui7tuhiZq2SuTtTCi0/0=
@@ -192,8 +190,6 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
@@ -308,8 +304,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c h1:VtwQ41oftZwlMnOEbMWQtSEUgU64U4s+GHk7hZK+jtY=
github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE=
github.com/matoous/go-nanoid v1.5.0/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U= github.com/matoous/go-nanoid v1.5.0/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U=
github.com/matoous/go-nanoid/v2 v2.0.0 h1:d19kur2QuLeHmJBkvYkFdhFBzLoo1XVm2GgTpL+9Tj0= github.com/matoous/go-nanoid/v2 v2.0.0 h1:d19kur2QuLeHmJBkvYkFdhFBzLoo1XVm2GgTpL+9Tj0=
github.com/matoous/go-nanoid/v2 v2.0.0/go.mod h1:FtS4aGPVfEkxKxhdWPAspZpZSh1cOjtM7Ej/So3hR0g= github.com/matoous/go-nanoid/v2 v2.0.0/go.mod h1:FtS4aGPVfEkxKxhdWPAspZpZSh1cOjtM7Ej/So3hR0g=
@@ -398,8 +392,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c h1:NRoLoZvkBTKvR5gQLgA3e0hqjkY9u1wm+iOL45VN/qI=
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@@ -450,8 +442,6 @@ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shirou/gopsutil/v4 v4.24.12 h1:qvePBOk20e0IKA1QXrIIU+jmk+zEiYVVx06WjBRlZo4=
github.com/shirou/gopsutil/v4 v4.24.12/go.mod h1:DCtMPAad2XceTeIAbGyVfycbYQNBGk2P8cvDi7/VN9o=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
@@ -488,10 +478,6 @@ github.com/tidwall/btree v1.7.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EU
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=
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
@@ -505,8 +491,6 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0=
go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
@@ -596,7 +580,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -606,7 +589,6 @@ golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -622,8 +604,6 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+154 -23
View File
@@ -18,6 +18,7 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode/utf8"
"github.com/GopeedLab/gopeed/internal/controller" "github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/internal/fetcher"
@@ -26,6 +27,7 @@ import (
"github.com/GopeedLab/gopeed/pkg/util" "github.com/GopeedLab/gopeed/pkg/util"
"github.com/xiaoqidun/setft" "github.com/xiaoqidun/setft"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"golang.org/x/text/encoding/simplifiedchinese"
) )
const ( const (
@@ -164,24 +166,7 @@ func (f *Fetcher) Resolve(req *base.Request) error {
} }
contentDisposition := httpResp.Header.Get(base.HttpHeaderContentDisposition) contentDisposition := httpResp.Header.Get(base.HttpHeaderContentDisposition)
if contentDisposition != "" { if contentDisposition != "" {
_, params, _ := mime.ParseMediaType(contentDisposition) file.Name = parseFilename(contentDisposition)
filename := params["filename"]
if filename != "" {
// Check if the filename is MIME encoded-word
if strings.HasPrefix(filename, "=?") {
decoder := new(mime.WordDecoder)
filename = strings.Replace(filename, "UTF8", "UTF-8", 1)
file.Name, _ = decoder.Decode(filename)
} else {
file.Name = util.TryUrlQueryUnescape(filename)
}
} else {
substr := "attachment; filename="
index := strings.Index(contentDisposition, substr)
if index != -1 {
file.Name = util.TryUrlQueryUnescape(contentDisposition[index+len(substr):])
}
}
} }
// get file filePath by URL // get file filePath by URL
if file.Name == "" { if file.Name == "" {
@@ -618,12 +603,158 @@ func (f *Fetcher) buildClient() *http.Client {
} }
} }
func decodeMangledString(mangled string) string { // parseFilename extracts filename from Content-Disposition header
rawBytes := make([]byte, 0, len(mangled)*3) // It handles multiple encoding scenarios:
for _, r := range mangled { // 1. RFC 5987/RFC 2231 format: filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip (preferred, checked first)
rawBytes = append(rawBytes, byte(r)) // 2. MIME encoded-word: filename="=?UTF-8?B?5rWL6K+VLnppcA==?="
// 3. URL-encoded: filename="%E6%B5%8B%E8%AF%95.zip"
// 4. Plain ASCII filename
//
// The key fix is checking filename*= first before mime.ParseMediaType, because
// some servers send Content-Disposition headers with invalid characters that cause
// mime.ParseMediaType to fail, but the filename*= parameter is still valid.
func parseFilename(contentDisposition string) string {
// First, try to find filename*= (RFC 5987 format, most reliable for non-ASCII)
if filename := parseFilenameExtended(contentDisposition); filename != "" {
return filename
} }
return string(rawBytes)
// Try standard MIME parsing for regular filename= parameter
_, params, err := mime.ParseMediaType(contentDisposition)
if err == nil {
if filename := params["filename"]; filename != "" {
return decodeFilenameParam(filename)
}
}
// Fallback: manual parsing if mime.ParseMediaType fails
return parseFilenameFallback(contentDisposition)
}
// parseFilenameExtended parses RFC 5987/RFC 2231 extended parameter format
// Format: filename*=charset'language'value (e.g., UTF-8''%E6%B5%8B%E8%AF%95.zip)
func parseFilenameExtended(cd string) string {
// Look for filename*= (case-insensitive)
lower := strings.ToLower(cd)
idx := strings.Index(lower, "filename*=")
if idx == -1 {
return ""
}
// Extract the value after filename*=
value := cd[idx+len("filename*="):]
// Find the end of the value (next ; or end of string)
if endIdx := strings.Index(value, ";"); endIdx != -1 {
value = value[:endIdx]
}
value = strings.TrimSpace(value)
// Parse charset'language'encoded-value format
// Common format: UTF-8''%E6%B5%8B%E8%AF%95.zip
parts := strings.SplitN(value, "''", 2)
if len(parts) == 2 {
// parts[0] is charset (e.g., "UTF-8")
// parts[1] is percent-encoded value
decoded, err := url.QueryUnescape(parts[1])
if err == nil {
return decoded
}
}
// Try with single quote delimiter as well (some servers use this)
parts = strings.SplitN(value, "'", 3)
if len(parts) >= 3 {
decoded, err := url.QueryUnescape(parts[2])
if err == nil {
return decoded
}
}
return ""
}
// decodeFilenameParam decodes a filename parameter value
// Handles MIME encoded-word, URL encoding, and GBK encoding fallback
func decodeFilenameParam(filename string) string {
// Check if the filename is MIME encoded-word (e.g., =?UTF-8?B?...?=)
if strings.HasPrefix(filename, "=?") {
decoder := new(mime.WordDecoder)
// Some servers use "UTF8" instead of "UTF-8", create a normalized copy
normalizedFilename := strings.Replace(filename, "UTF8", "UTF-8", 1)
if decoded, err := decoder.Decode(normalizedFilename); err == nil {
return decoded
}
}
// Try URL decoding
decoded := util.TryUrlQueryUnescape(filename)
// Check if the result is valid UTF-8. If not, try GBK decoding.
// This handles the case where Chinese Windows servers send GBK-encoded filenames
// which appear as garbled characters (e.g., "下载地址.zip" -> "���ص�ַ.zip")
if !utf8.ValidString(decoded) {
if gbkDecoded := tryDecodeGBK(decoded); gbkDecoded != "" {
return gbkDecoded
}
}
return decoded
}
// gbkDecoder is a reusable GBK decoder for better performance
var gbkDecoder = simplifiedchinese.GBK.NewDecoder()
// tryDecodeGBK attempts to decode a string as GBK/GB2312/GB18030 encoding
// Returns empty string if decoding fails or result is not valid UTF-8
func tryDecodeGBK(s string) string {
// GBK uses 1-2 bytes per character. Single-byte chars are 0x00-0x7F (ASCII compatible).
// Double-byte chars have first byte 0x81-0xFE and second byte 0x40-0xFE.
// Skip if string is empty or all ASCII (valid UTF-8)
if len(s) == 0 {
return ""
}
// Create a fresh decoder since the transform state may be corrupted
decoder := simplifiedchinese.GBK.NewDecoder()
decoded, err := decoder.Bytes([]byte(s))
if err != nil {
return ""
}
result := string(decoded)
if utf8.ValidString(result) {
return result
}
return ""
}
// parseFilenameFallback manually parses filename= when mime.ParseMediaType fails
func parseFilenameFallback(cd string) string {
// Look for filename= (case-insensitive)
lower := strings.ToLower(cd)
idx := strings.Index(lower, "filename=")
if idx == -1 {
return ""
}
// Skip "filename=" prefix
value := cd[idx+len("filename="):]
// Find the end of the value
if endIdx := strings.Index(value, ";"); endIdx != -1 {
value = value[:endIdx]
}
value = strings.TrimSpace(value)
// Remove quotes if present
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '\'' && value[len(value)-1] == '\'') {
value = value[1 : len(value)-1]
}
}
return decodeFilenameParam(value)
} }
type fetcherData struct { type fetcherData struct {
+39 -3
View File
@@ -42,7 +42,7 @@ func TestFetcher_Resolve(t *testing.T) {
Range: false, Range: false,
Files: []*base.FileInfo{ Files: []*base.FileInfo{
{ {
Name: "测试.zip", Name: test.TestChineseFileName,
Size: test.BuildSize, Size: test.BuildSize,
}, },
}, },
@@ -52,7 +52,7 @@ func TestFetcher_Resolve(t *testing.T) {
Range: false, Range: false,
Files: []*base.FileInfo{ Files: []*base.FileInfo{
{ {
Name: "测试.zip", Name: test.TestChineseFileName,
Size: test.BuildSize, Size: test.BuildSize,
}, },
}, },
@@ -62,7 +62,7 @@ func TestFetcher_Resolve(t *testing.T) {
Range: false, Range: false,
Files: []*base.FileInfo{ Files: []*base.FileInfo{
{ {
Name: "测试.zip", Name: test.TestChineseFileName,
Size: 0, Size: 0,
}, },
}, },
@@ -77,6 +77,42 @@ func TestFetcher_Resolve(t *testing.T) {
}, },
}, },
}, t) }, t)
// Test mixed encoding Content-Disposition where mime.ParseMediaType fails
// due to invalid characters, but filename*= contains the correct UTF-8 encoded name
testResolve(test.StartTestCustomServer, "mixed-encoding", &base.Resource{
Size: test.BuildSize,
Range: false,
Files: []*base.FileInfo{
{
Name: test.TestChineseFileName,
Size: test.BuildSize,
},
},
}, t)
// Test filename*= only (RFC 5987 format)
testResolve(test.StartTestCustomServer, "filename-star", &base.Resource{
Size: test.BuildSize,
Range: false,
Files: []*base.FileInfo{
{
Name: test.TestChineseFileName,
Size: test.BuildSize,
},
},
}, t)
// Test GBK-encoded filename (common on Chinese Windows servers)
// Before fix: "测试.zip" sent as GBK bytes -> parsed as "²âÊÔ.zip" (garbled)
// After fix: correctly decoded back to "测试.zip"
testResolve(test.StartTestCustomServer, "gbk-encoded", &base.Resource{
Size: test.BuildSize,
Range: false,
Files: []*base.FileInfo{
{
Name: test.TestChineseFileName,
Size: test.BuildSize,
},
},
}, t)
fetcher := buildFetcher() fetcher := buildFetcher()
err := fetcher.Resolve(&base.Request{ err := fetcher.Resolve(&base.Request{
+59 -3
View File
@@ -6,8 +6,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/armon/go-socks5"
"io" "io"
"net" "net"
"net/http" "net/http"
@@ -17,6 +15,10 @@ import (
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/armon/go-socks5"
"golang.org/x/text/encoding/simplifiedchinese"
) )
const ( const (
@@ -34,6 +36,10 @@ const (
DownloadRename = "download (1).data" DownloadRename = "download (1).data"
DownloadFile = Dir + DownloadName DownloadFile = Dir + DownloadName
DownloadRenameFile = Dir + DownloadRename DownloadRenameFile = Dir + DownloadRename
// TestChineseFileName is a common test filename with Chinese characters
// Used to test Content-Disposition parsing with various encodings
TestChineseFileName = "测试.zip"
) )
func StartTestFileServer() net.Listener { func StartTestFileServer() net.Listener {
@@ -103,7 +109,57 @@ func StartTestCustomServer() net.Listener {
io.Copy(writer, file) io.Copy(writer, file)
}) })
mux.HandleFunc("/no-encode", func(writer http.ResponseWriter, request *http.Request) { mux.HandleFunc("/no-encode", func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Disposition", "attachment; filename=测试.zip") writer.Header().Set("Content-Disposition", "attachment; filename="+TestChineseFileName)
writer.Header().Set("Content-Type", "application/octet-stream")
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
file, err := os.Open(BuildFile)
if err != nil {
panic(err)
}
defer file.Close()
io.Copy(writer, file)
})
// Test endpoint for mixed encoding: filename= with garbled characters (including special chars)
// and filename*= with proper UTF-8. This tests the case where mime.ParseMediaType fails
// due to invalid characters like <a> tags in the filename.
mux.HandleFunc("/mixed-encoding", func(writer http.ResponseWriter, request *http.Request) {
// This simulates a server that sends a garbled filename= with special chars that cause
// mime.ParseMediaType to fail, plus a proper filename*=UTF-8''...
// The filename*= should be preferred and correctly parsed.
writer.Header().Set("Content-Disposition", `attachment;filename="garbled<invalid>chars.zip";filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)
writer.Header().Set("Content-Type", "application/octet-stream")
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
file, err := os.Open(BuildFile)
if err != nil {
panic(err)
}
defer file.Close()
io.Copy(writer, file)
})
// Test endpoint for filename*= only (RFC 5987 format)
mux.HandleFunc("/filename-star", func(writer http.ResponseWriter, request *http.Request) {
// URL-encoded TestChineseFileName: 测试.zip -> %E6%B5%8B%E8%AF%95.zip
writer.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)
writer.Header().Set("Content-Type", "application/octet-stream")
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
file, err := os.Open(BuildFile)
if err != nil {
panic(err)
}
defer file.Close()
io.Copy(writer, file)
})
// Test endpoint for GBK-encoded filename (common on Chinese Windows servers)
// This simulates the case where Chinese characters are sent as GBK bytes
// which appear as garbled characters when interpreted as UTF-8.
// For example, "测试" in GBK is [B2 E2 CA D4] which is invalid UTF-8.
// Our fix detects invalid UTF-8 and attempts GBK decoding.
mux.HandleFunc("/gbk-encoded", func(writer http.ResponseWriter, request *http.Request) {
// Encode TestChineseFileName as GBK
gbkEncoder := simplifiedchinese.GBK.NewEncoder()
gbkBytes, _ := gbkEncoder.Bytes([]byte(TestChineseFileName))
// Send GBK bytes directly in filename (simulating broken server behavior)
writer.Header().Set("Content-Disposition", `attachment; filename="`+string(gbkBytes)+`"`)
writer.Header().Set("Content-Type", "application/octet-stream") writer.Header().Set("Content-Type", "application/octet-stream")
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize)) writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
file, err := os.Open(BuildFile) file, err := os.Open(BuildFile)
+13
View File
@@ -182,6 +182,7 @@ type DownloaderStoreConfig struct {
ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
Extra map[string]any `json:"extra"` Extra map[string]any `json:"extra"`
Proxy *DownloaderProxyConfig `json:"proxy"` Proxy *DownloaderProxyConfig `json:"proxy"`
Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration
} }
func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig { func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
@@ -194,6 +195,9 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
if cfg.Proxy == nil { if cfg.Proxy == nil {
cfg.Proxy = &DownloaderProxyConfig{} cfg.Proxy = &DownloaderProxyConfig{}
} }
if cfg.Webhook == nil {
cfg.Webhook = &WebhookConfig{}
}
return cfg return cfg
} }
@@ -216,9 +220,18 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
if cfg.Proxy == nil { if cfg.Proxy == nil {
cfg.Proxy = beforeCfg.Proxy cfg.Proxy = beforeCfg.Proxy
} }
if cfg.Webhook == nil {
cfg.Webhook = beforeCfg.Webhook
}
return cfg return cfg
} }
// WebhookConfig is the webhook configuration
type WebhookConfig struct {
Enable bool `json:"enable"` // Enable is the flag to enable/disable webhooks
URLs []string `json:"urls"` // URLs is the list of webhook URLs
}
type DownloaderProxyConfig struct { type DownloaderProxyConfig struct {
Enable bool `json:"enable"` Enable bool `json:"enable"`
// System is the flag that use system proxy // System is the flag that use system proxy
+7 -1
View File
@@ -18,6 +18,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
MaxRunning: 5, MaxRunning: 5,
ProtocolConfig: map[string]any{}, ProtocolConfig: map[string]any{},
Proxy: &DownloaderProxyConfig{}, Proxy: &DownloaderProxyConfig{},
Webhook: &WebhookConfig{},
}, },
}, },
{ {
@@ -29,6 +30,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
MaxRunning: 10, MaxRunning: 10,
ProtocolConfig: map[string]any{}, ProtocolConfig: map[string]any{},
Proxy: &DownloaderProxyConfig{}, Proxy: &DownloaderProxyConfig{},
Webhook: &WebhookConfig{},
}, },
}, },
{ {
@@ -43,7 +45,8 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
ProtocolConfig: map[string]any{ ProtocolConfig: map[string]any{
"key": "value", "key": "value",
}, },
Proxy: &DownloaderProxyConfig{}, Proxy: &DownloaderProxyConfig{},
Webhook: &WebhookConfig{},
}, },
}, },
{ {
@@ -59,6 +62,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
Proxy: &DownloaderProxyConfig{ Proxy: &DownloaderProxyConfig{
Enable: true, Enable: true,
}, },
Webhook: &WebhookConfig{},
}, },
}, },
} }
@@ -71,6 +75,7 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
ProtocolConfig: tt.fields.ProtocolConfig, ProtocolConfig: tt.fields.ProtocolConfig,
Extra: tt.fields.Extra, Extra: tt.fields.Extra,
Proxy: tt.fields.Proxy, Proxy: tt.fields.Proxy,
Webhook: tt.fields.Webhook,
} }
if got := cfg.Init(); !reflect.DeepEqual(got, tt.want) { if got := cfg.Init(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("Init() = %v, want %v", got, tt.want) t.Errorf("Init() = %v, want %v", got, tt.want)
@@ -255,6 +260,7 @@ func TestDownloaderStoreConfig_Merge(t *testing.T) {
ProtocolConfig: tt.fields.ProtocolConfig, ProtocolConfig: tt.fields.ProtocolConfig,
Extra: tt.fields.Extra, Extra: tt.fields.Extra,
Proxy: tt.fields.Proxy, Proxy: tt.fields.Proxy,
Webhook: tt.fields.Webhook,
} }
if got := cfg.Merge(tt.args.beforeCfg); !reflect.DeepEqual(got, tt.want) { if got := cfg.Merge(tt.args.beforeCfg); !reflect.DeepEqual(got, tt.want) {
t.Errorf("Merge() = %v, want %v", got, tt.want) t.Errorf("Merge() = %v, want %v", got, tt.want)
+5
View File
@@ -857,6 +857,7 @@ func (d *Downloader) watch(task *Task) {
d.emit(EventKeyFinally, task, err) d.emit(EventKeyFinally, task, err)
d.notifyRunning() d.notifyRunning()
d.triggerOnDone(task) d.triggerOnDone(task)
d.triggerWebhooks(WebhookEventDownloadDone, task, nil)
if e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok { if e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok {
downloadFilePath := task.Meta.SingleFilepath() downloadFilePath := task.Meta.SingleFilepath()
@@ -887,6 +888,7 @@ func (d *Downloader) doOnError(task *Task, err error) {
d.emit(EventKeyError, task, err) d.emit(EventKeyError, task, err)
d.emit(EventKeyFinally, task, err) d.emit(EventKeyFinally, task, err)
d.notifyRunning() d.notifyRunning()
d.triggerWebhooks(WebhookEventDownloadError, task, err)
} }
} }
@@ -941,6 +943,9 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
opts.Path = storeConfig.DownloadDir opts.Path = storeConfig.DownloadDir
} }
// Replace placeholders in download path (e.g., %year%, %month%, %day%, %date%)
opts.Path = util.ReplacePathPlaceholders(opts.Path)
// if enable white download directory, check if the download directory is in the white list // if enable white download directory, check if the download directory is in the white list
if len(d.cfg.WhiteDownloadDirs) > 0 { if len(d.cfg.WhiteDownloadDirs) > 0 {
inWhiteList := false inWhiteList := false
+229
View File
@@ -0,0 +1,229 @@
package download
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
)
const (
webhookTimeout = 10 * time.Second
)
// WebhookEvent represents the type of webhook event
type WebhookEvent string
const (
WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE"
WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR"
)
// WebhookData is the data sent to webhook URLs
type WebhookData struct {
Event WebhookEvent `json:"event"`
Time int64 `json:"time"` // Unix timestamp in milliseconds
Payload *WebhookPayload `json:"payload"`
}
// WebhookPayload contains the task data
type WebhookPayload struct {
Task *Task `json:"task"`
}
// getWebhookUrls extracts webhook URLs from config
// Supports both new webhook config format and legacy extra field for backward compatibility
func (d *Downloader) getWebhookUrls() []string {
cfg := d.cfg.DownloaderStoreConfig
if cfg == nil {
return nil
}
// Try new webhook config first
if cfg.Webhook != nil && cfg.Webhook.Enable && len(cfg.Webhook.URLs) > 0 {
urls := make([]string, 0, len(cfg.Webhook.URLs))
for _, url := range cfg.Webhook.URLs {
if url != "" {
urls = append(urls, url)
}
}
if len(urls) > 0 {
return urls
}
}
// Fall back to legacy extra field for backward compatibility
if cfg.Extra == nil {
return nil
}
webhookUrls, ok := cfg.Extra["webhookUrls"]
if !ok {
return nil
}
// Try direct string slice first
if urlsStr, ok := webhookUrls.([]string); ok {
if len(urlsStr) == 0 {
return nil
}
return urlsStr
}
// Convert []interface{} to []string
if urlsInterface, ok := webhookUrls.([]any); ok {
if len(urlsInterface) == 0 {
return nil
}
urls := make([]string, 0, len(urlsInterface))
for _, urlInterface := range urlsInterface {
if url, ok := urlInterface.(string); ok && url != "" {
urls = append(urls, url)
}
}
if len(urls) == 0 {
return nil
}
return urls
}
return nil
}
// sendWebhookToUrl sends webhook data to a single URL
// Returns the HTTP status code and any error that occurred
func (d *Downloader) sendWebhookToUrl(url string, data *WebhookData) (int, error) {
if url == "" {
return 0, fmt.Errorf("webhook URL is empty")
}
jsonData, err := json.Marshal(data)
if err != nil {
return 0, err
}
client := &http.Client{
Timeout: webhookTimeout,
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))
if err != nil {
return 0, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Gopeed-Webhook/1.0")
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
return resp.StatusCode, nil
}
// triggerWebhooks sends webhook notifications to all configured URLs
func (d *Downloader) triggerWebhooks(event WebhookEvent, task *Task, err error) {
urls := d.getWebhookUrls()
if len(urls) == 0 {
return
}
data := &WebhookData{
Event: event,
Time: time.Now().UnixMilli(),
Payload: &WebhookPayload{
Task: task.clone(),
},
}
go d.sendWebhooks(urls, data)
}
func (d *Downloader) sendWebhooks(urls []string, data *WebhookData) {
for _, url := range urls {
if url == "" {
continue
}
go func(webhookUrl string) {
statusCode, err := d.sendWebhookToUrl(webhookUrl, data)
if err != nil {
d.Logger.Warn().Err(err).Str("url", webhookUrl).Msg("webhook: failed to send request")
return
}
if statusCode >= 200 && statusCode < 300 {
d.Logger.Debug().Str("url", webhookUrl).Int("status", statusCode).Msg("webhook: sent successfully")
} else {
d.Logger.Warn().Str("url", webhookUrl).Int("status", statusCode).Msg("webhook: received non-success status")
}
}(url)
}
}
// SendTestWebhook sends a test webhook with a simulated payload
// Returns error if any webhook URL does not respond with HTTP 200
func (d *Downloader) SendTestWebhook() error {
urls := d.getWebhookUrls()
if len(urls) == 0 {
return nil
}
for _, url := range urls {
if url == "" {
continue
}
if err := d.TestWebhookUrl(url); err != nil {
return err
}
}
return nil
}
// TestWebhookUrl tests a single webhook URL with a simulated payload
// Returns error if the URL does not respond with HTTP 200
func (d *Downloader) TestWebhookUrl(url string) error {
// Create a simulated test task with minimal required fields
testTask := NewTask()
testTask.Protocol = "http"
testTask.Status = base.DownloadStatusDone
testTask.Meta = &fetcher.FetcherMeta{
Req: &base.Request{
URL: "https://example.com/test-file.zip",
},
Opts: &base.Options{
Name: "test-file.zip",
Path: "/downloads",
},
Res: &base.Resource{
Size: 1024 * 1024 * 100, // 100MB
Files: []*base.FileInfo{
{Name: "test-file.zip", Size: 1024 * 1024 * 100},
},
},
}
// Create test data
testData := &WebhookData{
Event: WebhookEventDownloadDone,
Time: time.Now().UnixMilli(),
Payload: &WebhookPayload{
Task: testTask,
},
}
statusCode, err := d.sendWebhookToUrl(url, testData)
if err != nil {
return err
}
if statusCode != http.StatusOK {
return fmt.Errorf("webhook test failed: %s returned status %d", url, statusCode)
}
return nil
}
+827
View File
@@ -0,0 +1,827 @@
package download
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
)
var mockFetcherMeta = fetcher.FetcherMeta{
Req: &base.Request{
URL: "https://example.com/test.zip",
},
Opts: &base.Options{
Path: "/downloads",
},
Res: &base.Resource{
Size: 1024 * 1024,
Files: []*base.FileInfo{
{Name: "test.zip", Size: 1024 * 1024},
},
},
}
func TestWebhook_TriggerOnDone(t *testing.T) {
receivedData := make(chan *WebhookData, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("Expected POST request, got %s", r.Method)
}
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
}
var data WebhookData
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
t.Errorf("Failed to decode data: %v", err)
return
}
receivedData <- &data
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
// Configure webhook URLs
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{
Enable: true,
URLs: []string{server.URL},
}
downloader.PutConfig(cfg)
// Create a mock task
task := NewTask()
task.Protocol = "http"
task.Meta = &mockFetcherMeta
// Trigger webhook
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
select {
case data := <-receivedData:
if data.Event != WebhookEventDownloadDone {
t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event)
}
if data.Payload == nil || data.Payload.Task == nil {
t.Error("Expected payload.task to be present")
} else if data.Payload.Task.ID != task.ID {
t.Errorf("Expected task ID '%s', got '%s'", task.ID, data.Payload.Task.ID)
}
if data.Time == 0 {
t.Error("Expected time to be set")
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for webhook")
}
})
}
func TestWebhook_TriggerOnError(t *testing.T) {
receivedData := make(chan *WebhookData, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var data WebhookData
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
t.Errorf("Failed to decode data: %v", err)
return
}
receivedData <- &data
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
// Configure webhook URLs
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{
Enable: true,
URLs: []string{server.URL},
}
downloader.PutConfig(cfg)
// Create a mock task
task := NewTask()
task.Protocol = "http"
task.Meta = &mockFetcherMeta
// Trigger webhook with error
testError := http.ErrServerClosed
downloader.triggerWebhooks(WebhookEventDownloadError, task, testError)
select {
case data := <-receivedData:
if data.Event != WebhookEventDownloadError {
t.Errorf("Expected event 'DOWNLOAD_ERROR', got '%s'", data.Event)
}
if data.Payload == nil || data.Payload.Task == nil {
t.Error("Expected payload.task to be present")
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for webhook")
}
})
}
func TestWebhook_SendTestWebhook(t *testing.T) {
receivedData := make(chan *WebhookData, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var data WebhookData
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
t.Errorf("Failed to decode data: %v", err)
return
}
receivedData <- &data
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
// Configure webhook URLs
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{
Enable: true,
URLs: []string{server.URL},
}
downloader.PutConfig(cfg)
// Send test webhook
err := downloader.SendTestWebhook()
if err != nil {
t.Errorf("SendTestWebhook failed: %v", err)
}
select {
case data := <-receivedData:
if data.Event != WebhookEventDownloadDone {
t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event)
}
if data.Payload == nil || data.Payload.Task == nil {
t.Error("Expected payload.task to be present")
}
if data.Time == 0 {
t.Error("Expected time to be set")
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for webhook")
}
})
}
func TestWebhook_NoWebhookConfigured(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
// Create a mock task
task := NewTask()
task.Protocol = "http"
task.Meta = &mockFetcherMeta
// Trigger webhook (should not panic with no webhooks configured)
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
// Send test webhook (should not panic)
err := downloader.SendTestWebhook()
if err != nil {
t.Errorf("SendTestWebhook failed: %v", err)
}
})
}
func TestWebhook_MultipleUrls(t *testing.T) {
count := 0
server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count++
w.WriteHeader(http.StatusOK)
}))
defer server1.Close()
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
count++
w.WriteHeader(http.StatusOK)
}))
defer server2.Close()
setupWebhookTest(t, func(downloader *Downloader) {
// Configure multiple webhook URLs
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{
Enable: true,
URLs: []string{server1.URL, server2.URL},
}
downloader.PutConfig(cfg)
// Create a mock task
task := NewTask()
task.Protocol = "http"
task.Meta = &mockFetcherMeta
// Trigger webhook
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
// Wait for webhooks
time.Sleep(500 * time.Millisecond)
if count != 2 {
t.Errorf("Expected 2 webhook calls, got %d", count)
}
})
}
func TestWebhook_TestWebhookFailsOnNon200(t *testing.T) {
// Test that SendTestWebhook returns error for non-200 status codes
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError) // 500
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
// Configure webhook URLs
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{
Enable: true,
URLs: []string{server.URL},
}
downloader.PutConfig(cfg)
// Send test webhook - should fail with non-200 status
err := downloader.SendTestWebhook()
if err == nil {
t.Error("Expected SendTestWebhook to return error for non-200 status")
}
})
}
func TestWebhook_TestWebhookFailsOn201(t *testing.T) {
// Test that SendTestWebhook returns error for 201 (only 200 is success)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated) // 201
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
// Configure webhook URLs
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{
Enable: true,
URLs: []string{server.URL},
}
downloader.PutConfig(cfg)
// Send test webhook - should fail with 201 status (only 200 is success)
err := downloader.SendTestWebhook()
if err == nil {
t.Error("Expected SendTestWebhook to return error for 201 status")
}
})
}
func TestWebhook_TestWebhookUrl(t *testing.T) {
receivedData := make(chan *WebhookData, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var data WebhookData
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
t.Errorf("Failed to decode data: %v", err)
return
}
receivedData <- &data
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
// Test single URL
err := downloader.TestWebhookUrl(server.URL)
if err != nil {
t.Errorf("TestWebhookUrl failed: %v", err)
}
select {
case data := <-receivedData:
if data.Event != WebhookEventDownloadDone {
t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event)
}
if data.Payload == nil || data.Payload.Task == nil {
t.Error("Expected payload.task to be present")
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for webhook")
}
})
}
func TestWebhook_TestWebhookUrlEmpty(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
// Test with empty URL - should return error
err := downloader.TestWebhookUrl("")
if err == nil {
t.Error("Expected TestWebhookUrl to return error for empty URL")
}
})
}
func TestWebhook_GetWebhookUrls_EmptyConfig(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
urls := downloader.getWebhookUrls()
if urls != nil {
t.Errorf("Expected nil, got %v", urls)
}
})
}
func TestWebhook_GetWebhookUrls_NoExtraField(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Webhook = nil
downloader.PutConfig(cfg)
urls := downloader.getWebhookUrls()
if urls != nil {
t.Errorf("Expected nil, got %v", urls)
}
})
}
func TestWebhook_GetWebhookUrls_NoWebhookUrlsKey(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{Enable: true} // No URLs set
downloader.PutConfig(cfg)
urls := downloader.getWebhookUrls()
if urls != nil {
t.Errorf("Expected nil, got %v", urls)
}
})
}
func TestWebhook_GetWebhookUrls_StringSlice(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{
Enable: true,
URLs: []string{"http://example.com", "http://example2.com"},
}
downloader.PutConfig(cfg)
urls := downloader.getWebhookUrls()
if len(urls) != 2 {
t.Errorf("Expected 2 URLs, got %d", len(urls))
}
if urls[0] != "http://example.com" || urls[1] != "http://example2.com" {
t.Errorf("URLs don't match expected values: %v", urls)
}
})
}
func TestWebhook_GetWebhookUrls_InterfaceSlice(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{
Enable: true,
URLs: []string{"http://example.com", "http://example2.com"},
}
downloader.PutConfig(cfg)
urls := downloader.getWebhookUrls()
if len(urls) != 2 {
t.Errorf("Expected 2 URLs, got %d", len(urls))
}
if urls[0] != "http://example.com" || urls[1] != "http://example2.com" {
t.Errorf("URLs don't match expected values: %v", urls)
}
})
}
func TestWebhook_GetWebhookUrls_EmptyStringSlice(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Extra = make(map[string]any)
cfg.Webhook.URLs = []string{}
downloader.PutConfig(cfg)
urls := downloader.getWebhookUrls()
if urls != nil {
t.Errorf("Expected nil, got %v", urls)
}
})
}
func TestWebhook_GetWebhookUrls_InterfaceSliceWithEmptyStrings(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{
Enable: true,
URLs: []string{"", "", ""},
}
downloader.PutConfig(cfg)
urls := downloader.getWebhookUrls()
if urls != nil {
t.Errorf("Expected nil for all empty strings, got %v", urls)
}
})
}
func TestWebhook_GetWebhookUrls_InterfaceSliceMixedTypes(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{
Enable: true,
URLs: []string{"http://example.com", "", "http://example2.com", ""},
}
downloader.PutConfig(cfg)
urls := downloader.getWebhookUrls()
if len(urls) != 2 {
t.Errorf("Expected 2 valid URLs (ignoring empty strings), got %d: %v", len(urls), urls)
}
if urls[0] != "http://example.com" || urls[1] != "http://example2.com" {
t.Errorf("URLs don't match expected values: %v", urls)
}
})
}
func TestWebhook_GetWebhookUrls_InvalidType(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{Enable: false} // Disabled webhook
downloader.PutConfig(cfg)
urls := downloader.getWebhookUrls()
if urls != nil {
t.Errorf("Expected nil for disabled webhook, got %v", urls)
}
})
}
func TestWebhook_GetWebhookUrls_DisabledWebhook(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{
Enable: false,
URLs: []string{"http://example.com"},
}
downloader.PutConfig(cfg)
urls := downloader.getWebhookUrls()
if urls != nil {
t.Errorf("Expected nil for disabled webhook even with URLs, got %v", urls)
}
})
}
func TestWebhook_SendWebhookToUrl_Success(t *testing.T) {
receivedData := make(chan *WebhookData, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify headers
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
}
if r.Header.Get("User-Agent") != "Gopeed-Webhook/1.0" {
t.Errorf("Expected User-Agent Gopeed-Webhook/1.0, got %s", r.Header.Get("User-Agent"))
}
var data WebhookData
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
t.Errorf("Failed to decode data: %v", err)
return
}
receivedData <- &data
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
task := NewTask()
task.Protocol = "http"
task.Meta = &mockFetcherMeta
data := &WebhookData{
Event: WebhookEventDownloadDone,
Time: time.Now().UnixMilli(),
Payload: &WebhookPayload{
Task: task,
},
}
statusCode, err := downloader.sendWebhookToUrl(server.URL, data)
if err != nil {
t.Errorf("sendWebhookToUrl failed: %v", err)
}
if statusCode != http.StatusOK {
t.Errorf("Expected status 200, got %d", statusCode)
}
select {
case received := <-receivedData:
if received.Event != WebhookEventDownloadDone {
t.Errorf("Expected event DOWNLOAD_DONE, got %s", received.Event)
}
case <-time.After(1 * time.Second):
t.Error("Timeout waiting for webhook")
}
})
}
func TestWebhook_SendWebhookToUrl_EmptyUrl(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
data := &WebhookData{
Event: WebhookEventDownloadDone,
Time: time.Now().UnixMilli(),
}
_, err := downloader.sendWebhookToUrl("", data)
if err == nil {
t.Error("Expected error for empty URL")
}
if err.Error() != "webhook URL is empty" {
t.Errorf("Expected 'webhook URL is empty' error, got: %v", err)
}
})
}
func TestWebhook_SendWebhookToUrl_InvalidUrl(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
data := &WebhookData{
Event: WebhookEventDownloadDone,
Time: time.Now().UnixMilli(),
}
_, err := downloader.sendWebhookToUrl("://invalid-url", data)
if err == nil {
t.Error("Expected error for invalid URL")
}
})
}
func TestWebhook_SendWebhookToUrl_NonExistentHost(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
data := &WebhookData{
Event: WebhookEventDownloadDone,
Time: time.Now().UnixMilli(),
}
_, err := downloader.sendWebhookToUrl("http://non-existent-host-12345.example", data)
if err == nil {
t.Error("Expected error for non-existent host")
}
})
}
func TestWebhook_SendWebhookToUrl_Timeout(t *testing.T) {
// Create a server that delays response beyond webhook timeout
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(15 * time.Second) // Longer than webhookTimeout (10s)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
data := &WebhookData{
Event: WebhookEventDownloadDone,
Time: time.Now().UnixMilli(),
}
_, err := downloader.sendWebhookToUrl(server.URL, data)
if err == nil {
t.Error("Expected timeout error")
}
})
}
func TestWebhook_SendWebhookToUrl_VariousStatusCodes(t *testing.T) {
testCases := []struct {
name string
statusCode int
}{
{"200 OK", http.StatusOK},
{"201 Created", http.StatusCreated},
{"204 No Content", http.StatusNoContent},
{"400 Bad Request", http.StatusBadRequest},
{"404 Not Found", http.StatusNotFound},
{"500 Internal Server Error", http.StatusInternalServerError},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.statusCode)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
data := &WebhookData{
Event: WebhookEventDownloadDone,
Time: time.Now().UnixMilli(),
}
statusCode, err := downloader.sendWebhookToUrl(server.URL, data)
if err != nil {
t.Errorf("sendWebhookToUrl failed: %v", err)
}
if statusCode != tc.statusCode {
t.Errorf("Expected status %d, got %d", tc.statusCode, statusCode)
}
})
})
}
}
func TestWebhook_TriggerWebhooks_EmptyUrlSkipped(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{
Enable: true,
URLs: []string{server.URL, "", server.URL},
}
downloader.PutConfig(cfg)
task := NewTask()
task.Protocol = "http"
task.Meta = &mockFetcherMeta
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
time.Sleep(500 * time.Millisecond)
if requestCount != 2 {
t.Errorf("Expected 2 requests (empty URL should be skipped), got %d", requestCount)
}
})
}
func TestWebhook_WebhookDataStructure(t *testing.T) {
receivedData := make(chan *WebhookData, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var data WebhookData
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
t.Errorf("Failed to decode data: %v", err)
return
}
receivedData <- &data
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{
Enable: true,
URLs: []string{server.URL},
}
downloader.PutConfig(cfg)
task := NewTask()
task.Protocol = "http"
task.Status = base.DownloadStatusDone
task.Meta = &mockFetcherMeta
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
select {
case data := <-receivedData:
// Verify event
if data.Event != WebhookEventDownloadDone {
t.Errorf("Expected event DOWNLOAD_DONE, got %s", data.Event)
}
// Verify time is set
if data.Time == 0 {
t.Error("Expected time to be set")
}
// Verify payload
if data.Payload == nil {
t.Error("Expected payload to be present")
}
if data.Payload.Task == nil {
t.Error("Expected task in payload to be present")
}
// Verify task is cloned (has same ID but different pointer)
if data.Payload.Task.ID != task.ID {
t.Errorf("Expected task ID %s, got %s", task.ID, data.Payload.Task.ID)
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for webhook")
}
})
}
func TestWebhook_SendTestWebhook_EmptyUrls(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Extra = make(map[string]any)
cfg.Webhook.URLs = []string{}
downloader.PutConfig(cfg)
err := downloader.SendTestWebhook()
if err != nil {
t.Errorf("Expected no error for empty URLs, got: %v", err)
}
})
}
func TestWebhook_SendTestWebhook_MixedResults(t *testing.T) {
// First server returns 200
server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server1.Close()
// Second server returns 500
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server2.Close()
setupWebhookTest(t, func(downloader *Downloader) {
cfg, _ := downloader.GetConfig()
cfg.Webhook = &base.WebhookConfig{
Enable: true,
URLs: []string{server1.URL, server2.URL},
}
downloader.PutConfig(cfg)
// Should fail because server2 returns 500
err := downloader.SendTestWebhook()
if err == nil {
t.Error("Expected error when one server returns non-200 status")
}
})
}
func TestWebhook_TestWebhookUrl_InvalidUrl(t *testing.T) {
setupWebhookTest(t, func(downloader *Downloader) {
err := downloader.TestWebhookUrl("://invalid")
if err == nil {
t.Error("Expected error for invalid URL")
}
})
}
func TestWebhook_TestWebhookUrl_VerifyTestPayload(t *testing.T) {
receivedData := make(chan *WebhookData, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var data WebhookData
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
t.Errorf("Failed to decode data: %v", err)
return
}
receivedData <- &data
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
setupWebhookTest(t, func(downloader *Downloader) {
err := downloader.TestWebhookUrl(server.URL)
if err != nil {
t.Errorf("TestWebhookUrl failed: %v", err)
}
select {
case data := <-receivedData:
// Verify it's a test webhook
if data.Event != WebhookEventDownloadDone {
t.Errorf("Expected event DOWNLOAD_DONE, got %s", data.Event)
}
if data.Payload == nil || data.Payload.Task == nil {
t.Error("Expected payload with task")
}
// Verify test task properties
task := data.Payload.Task
if task.Protocol != "http" {
t.Errorf("Expected protocol 'http', got '%s'", task.Protocol)
}
if task.Status != base.DownloadStatusDone {
t.Errorf("Expected status Done, got %s", task.Status)
}
if task.Meta == nil || task.Meta.Req == nil {
t.Error("Expected meta and request in test task")
}
if task.Meta.Req.URL != "https://example.com/test-file.zip" {
t.Errorf("Expected test URL, got %s", task.Meta.Req.URL)
}
case <-time.After(2 * time.Second):
t.Error("Timeout waiting for webhook")
}
})
}
func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) {
defaultDownloader.Setup()
defaultDownloader.cfg.StorageDir = ".test_storage"
defaultDownloader.cfg.DownloadDir = ".test_download"
defer func() {
defaultDownloader.Clear()
os.RemoveAll(defaultDownloader.cfg.StorageDir)
os.RemoveAll(defaultDownloader.cfg.DownloadDir)
}()
fn(defaultDownloader)
}
+11
View File
@@ -389,3 +389,14 @@ func getServerConfig() *base.DownloaderStoreConfig {
cfg, _ := Downloader.GetConfig() cfg, _ := Downloader.GetConfig()
return cfg return cfg
} }
func TestWebhook(w http.ResponseWriter, r *http.Request) {
var req model.TestWebhookReq
if ReadJson(r, w, &req) {
if err := Downloader.TestWebhookUrl(req.URL); err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
WriteJson(w, model.NewNilResult())
}
}
+6
View File
@@ -0,0 +1,6 @@
package model
// TestWebhookReq is the request body for testing a single webhook URL
type TestWebhookReq struct {
URL string `json:"url"`
}
+1
View File
@@ -141,6 +141,7 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
r.Methods(http.MethodDelete).Path("/api/v1/extensions/{identity}").HandlerFunc(DeleteExtension) r.Methods(http.MethodDelete).Path("/api/v1/extensions/{identity}").HandlerFunc(DeleteExtension)
r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateCheckExtension) r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateCheckExtension)
r.Methods(http.MethodPost).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateExtension) r.Methods(http.MethodPost).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateExtension)
r.Methods(http.MethodPost).Path("/api/v1/webhook/test").HandlerFunc(TestWebhook)
r.Path("/api/v1/proxy").HandlerFunc(DoProxy) r.Path("/api/v1/proxy").HandlerFunc(DoProxy)
enableApiToken := startCfg.ApiToken != "" enableApiToken := startCfg.ApiToken != ""
+115 -4
View File
@@ -6,10 +6,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/rest/model"
"io" "io"
"net" "net"
"net/http" "net/http"
@@ -20,6 +16,11 @@ import (
"sync" "sync"
"testing" "testing"
"time" "time"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/rest/model"
) )
var ( var (
@@ -606,6 +607,116 @@ func TestDoProxy(t *testing.T) {
}) })
} }
func TestTestWebhook(t *testing.T) {
doTest(func() {
// Set up a mock webhook server
webhookReceived := false
var receivedData map[string]interface{}
var wg sync.WaitGroup
wg.Add(1)
webhookServer := http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if err := json.Unmarshal(body, &receivedData); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// Check Content-Type
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("TestWebhook() Content-Type got = %v, want %v", r.Header.Get("Content-Type"), "application/json")
}
webhookReceived = true
w.WriteHeader(http.StatusOK)
wg.Done()
}),
}
// Start webhook server
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
webhookPort := listener.Addr().(*net.TCPAddr).Port
webhookURL := fmt.Sprintf("http://127.0.0.1:%d/webhook", webhookPort)
go webhookServer.Serve(listener)
defer webhookServer.Close()
// Test with valid webhook URL
httpRequestCheckOk[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
URL: webhookURL,
})
// Wait for webhook to be received
wg.Wait()
if !webhookReceived {
t.Error("TestWebhook() webhook was not received")
}
// Verify webhook data structure
if receivedData["event"] == nil {
t.Error("TestWebhook() missing 'event' field")
}
if receivedData["time"] == nil {
t.Error("TestWebhook() missing 'time' field")
}
if receivedData["payload"] == nil {
t.Error("TestWebhook() missing 'payload' field")
}
// Test with invalid webhook URL
code, _ := httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
URL: "http://invalid-webhook-url-that-does-not-exist.local:99999/webhook",
})
checkCode(code, model.CodeError)
// Test with empty URL
code, _ = httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
URL: "",
})
checkCode(code, model.CodeError)
// Test with webhook server returning non-200 status
wg.Add(1)
badWebhookServer := http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
wg.Done()
}),
}
badListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
badWebhookPort := badListener.Addr().(*net.TCPAddr).Port
badWebhookURL := fmt.Sprintf("http://127.0.0.1:%d/webhook", badWebhookPort)
go badWebhookServer.Serve(badListener)
defer badWebhookServer.Close()
code, _ = httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
URL: badWebhookURL,
})
checkCode(code, model.CodeError)
wg.Wait()
})
}
func TestApiToken(t *testing.T) { func TestApiToken(t *testing.T) {
var cfg = &model.StartConfig{} var cfg = &model.StartConfig{}
cfg.Init() cfg.Init()
+34 -3
View File
@@ -8,6 +8,7 @@ import (
syspath "path" syspath "path"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
) )
func Dir(path string) string { func Dir(path string) string {
@@ -50,12 +51,17 @@ func CheckDuplicateAndRename(path string) (string, error) {
return "", err return "", err
} }
index := strings.LastIndex(name, ".") ext := syspath.Ext(name)
var nameTpl string var nameTpl string
if index == -1 { // Special case: if the extension is the entire filename (like .gitignore),
// or if index of last dot is 0 (starts with dot), treat it as no extension
if ext == "" || ext == name || (len(ext) > 0 && strings.LastIndex(name, ".") == 0) {
// No extension or hidden file without extension
nameTpl = name + " (%d)" nameTpl = name + " (%d)"
} else { } else {
nameTpl = name[:index] + " (%d)" + name[index:] // Has extension
nameWithoutExt := name[:len(name)-len(ext)]
nameTpl = nameWithoutExt + " (%d)" + ext
} }
for i := 1; ; i++ { for i := 1; ; i++ {
newName := fmt.Sprintf(nameTpl, i) newName := fmt.Sprintf(nameTpl, i)
@@ -194,3 +200,28 @@ func ReplaceInvalidFilename(path string) string {
} }
return path return path
} }
// ReplacePathPlaceholders replaces date placeholders in a path with actual values
// Supported placeholders:
// - %year% - Current year (e.g., 2025)
// - %month% - Current month (01-12)
// - %day% - Current day (01-31)
// - %date% - Full date format (2025-01-01)
func ReplacePathPlaceholders(path string) string {
if path == "" {
return ""
}
now := time.Now()
year := fmt.Sprintf("%d", now.Year())
month := fmt.Sprintf("%02d", now.Month())
day := fmt.Sprintf("%02d", now.Day())
date := fmt.Sprintf("%s-%s-%s", year, month, day)
path = strings.ReplaceAll(path, "%year%", year)
path = strings.ReplaceAll(path, "%month%", month)
path = strings.ReplaceAll(path, "%day%", day)
path = strings.ReplaceAll(path, "%date%", date)
return path
}
+90
View File
@@ -1,8 +1,11 @@
package util package util
import ( import (
"fmt"
"os" "os"
"strings"
"testing" "testing"
"time"
) )
func TestDir(t *testing.T) { func TestDir(t *testing.T) {
@@ -106,13 +109,28 @@ func TestSafeRemove(t *testing.T) {
} }
func TestCheckDuplicateAndRename(t *testing.T) { func TestCheckDuplicateAndRename(t *testing.T) {
// Test with extension
doCheckDuplicateAndRename(t, []string{}, "a.txt", "a.txt") doCheckDuplicateAndRename(t, []string{}, "a.txt", "a.txt")
doCheckDuplicateAndRename(t, []string{"a.txt"}, "a.txt", "a (1).txt") doCheckDuplicateAndRename(t, []string{"a.txt"}, "a.txt", "a (1).txt")
doCheckDuplicateAndRename(t, []string{"a.txt", "a (1).txt"}, "a.txt", "a (2).txt") doCheckDuplicateAndRename(t, []string{"a.txt", "a (1).txt"}, "a.txt", "a (2).txt")
// Test without extension
doCheckDuplicateAndRename(t, []string{}, "a", "a") doCheckDuplicateAndRename(t, []string{}, "a", "a")
doCheckDuplicateAndRename(t, []string{"a"}, "a", "a (1)") doCheckDuplicateAndRename(t, []string{"a"}, "a", "a (1)")
doCheckDuplicateAndRename(t, []string{"a", "a (1)"}, "a", "a (2)") doCheckDuplicateAndRename(t, []string{"a", "a (1)"}, "a", "a (2)")
// Test hidden files (starting with dot)
doCheckDuplicateAndRename(t, []string{}, ".gitignore", ".gitignore")
doCheckDuplicateAndRename(t, []string{".gitignore"}, ".gitignore", ".gitignore (1)")
doCheckDuplicateAndRename(t, []string{".gitignore", ".gitignore (1)"}, ".gitignore", ".gitignore (2)")
// Test hidden files with extension
doCheckDuplicateAndRename(t, []string{}, ".config.json", ".config.json")
doCheckDuplicateAndRename(t, []string{".config.json"}, ".config.json", ".config (1).json")
// Test multiple dots
doCheckDuplicateAndRename(t, []string{}, "test.tar.gz", "test.tar.gz")
doCheckDuplicateAndRename(t, []string{"test.tar.gz"}, "test.tar.gz", "test.tar (1).gz")
} }
func doCheckDuplicateAndRename(t *testing.T, exitsPaths []string, path string, except string) { func doCheckDuplicateAndRename(t *testing.T, exitsPaths []string, path string, except string) {
@@ -225,3 +243,75 @@ func TestReplaceInvalidFilename(t *testing.T) {
}) })
} }
} }
func TestReplacePathPlaceholders(t *testing.T) {
now := time.Now()
year := fmt.Sprintf("%d", now.Year())
month := fmt.Sprintf("%02d", now.Month())
day := fmt.Sprintf("%02d", now.Day())
date := fmt.Sprintf("%s-%s-%s", year, month, day)
tests := []struct {
name string
path string
want string
}{
{
name: "empty path",
path: "",
want: "",
},
{
name: "no placeholders",
path: "/home/user/Downloads",
want: "/home/user/Downloads",
},
{
name: "year placeholder",
path: "/Downloads/%year%",
want: "/Downloads/" + year,
},
{
name: "month placeholder",
path: "/Downloads/%month%",
want: "/Downloads/" + month,
},
{
name: "day placeholder",
path: "/Downloads/%day%",
want: "/Downloads/" + day,
},
{
name: "date placeholder",
path: "/Downloads/%date%",
want: "/Downloads/" + date,
},
{
name: "multiple placeholders",
path: "/Downloads/%year%-%month%",
want: "/Downloads/" + year + "-" + month,
},
{
name: "mixed path with placeholders",
path: "/home/user/Downloads/%year%/%month%/%day%",
want: "/home/user/Downloads/" + year + "/" + month + "/" + day,
},
{
name: "windows style path",
path: "D:\\Downloads\\%year%-%month%",
want: "D:\\Downloads\\" + year + "-" + month,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ReplacePathPlaceholders(tt.path)
if !strings.Contains(got, year) && tt.path != "" && strings.Contains(tt.path, "%year%") {
t.Errorf("ReplacePathPlaceholders() = %v, want containing year %v", got, year)
}
if got != tt.want {
t.Errorf("ReplacePathPlaceholders() = %v, want %v", got, tt.want)
}
})
}
}
+5
View File
@@ -236,6 +236,11 @@ Future<void> updateExtension(String identity) async {
() => _client.dio.post("api/v1/extensions/$identity/update"), null); () => _client.dio.post("api/v1/extensions/$identity/update"), null);
} }
Future<void> testWebhook(String url) async {
return _parse(
() => _client.dio.post("api/v1/webhook/test", data: {"url": url}), null);
}
Future<String> login(LoginReq loginReq) async { Future<String> login(LoginReq loginReq) async {
return _parse(() => _client.dio.post("api/web/login", data: loginReq), return _parse(() => _client.dio.post("api/web/login", data: loginReq),
(data) => data as String); (data) => data as String);
@@ -9,6 +9,7 @@ class DownloaderConfig {
ProtocolConfig protocolConfig = ProtocolConfig(); ProtocolConfig protocolConfig = ProtocolConfig();
ExtraConfig extra = ExtraConfig(); ExtraConfig extra = ExtraConfig();
ProxyConfig proxy = ProxyConfig(); ProxyConfig proxy = ProxyConfig();
WebhookConfig webhook = WebhookConfig();
DownloaderConfig({ DownloaderConfig({
this.downloadDir = '', this.downloadDir = '',
@@ -82,8 +83,10 @@ class ExtraConfig {
bool defaultDirectDownload; bool defaultDirectDownload;
bool defaultBtClient; bool defaultBtClient;
bool notifyWhenNewVersion; bool notifyWhenNewVersion;
List<DownloadCategory> downloadCategories;
ExtraConfigBt bt = ExtraConfigBt(); ExtraConfigBt bt = ExtraConfigBt();
ExtraConfigGithubMirror githubMirror = ExtraConfigGithubMirror();
ExtraConfig({ ExtraConfig({
this.themeMode = '', this.themeMode = '',
@@ -92,6 +95,7 @@ class ExtraConfig {
this.defaultDirectDownload = false, this.defaultDirectDownload = false,
this.defaultBtClient = true, this.defaultBtClient = true,
this.notifyWhenNewVersion = true, this.notifyWhenNewVersion = true,
this.downloadCategories = const [],
}); });
factory ExtraConfig.fromJson(Map<String, dynamic>? json) => factory ExtraConfig.fromJson(Map<String, dynamic>? json) =>
@@ -100,6 +104,45 @@ class ExtraConfig {
Map<String, dynamic> toJson() => _$ExtraConfigToJson(this); Map<String, dynamic> toJson() => _$ExtraConfigToJson(this);
} }
@JsonSerializable()
class DownloadCategory {
String name;
String path;
bool isBuiltIn;
String? nameKey; // i18n key for built-in categories (e.g., 'categoryMusic')
bool
isDeleted; // Mark built-in categories as deleted instead of removing them
DownloadCategory({
required this.name,
required this.path,
this.isBuiltIn = false,
this.nameKey,
this.isDeleted = false,
});
factory DownloadCategory.fromJson(Map<String, dynamic> json) =>
_$DownloadCategoryFromJson(json);
Map<String, dynamic> toJson() => _$DownloadCategoryToJson(this);
}
@JsonSerializable()
class WebhookConfig {
bool enable;
List<String> urls;
WebhookConfig({
this.enable = false,
this.urls = const [],
});
factory WebhookConfig.fromJson(Map<String, dynamic>? json) =>
json == null ? WebhookConfig() : _$WebhookConfigFromJson(json);
Map<String, dynamic> toJson() => _$WebhookConfigToJson(this);
}
@JsonSerializable() @JsonSerializable()
class ProxyConfig { class ProxyConfig {
bool enable; bool enable;
@@ -140,3 +183,46 @@ class ExtraConfigBt {
Map<String, dynamic> toJson() => _$ExtraConfigBtToJson(this); Map<String, dynamic> toJson() => _$ExtraConfigBtToJson(this);
} }
enum GithubMirrorType {
jsdelivr,
ghProxy,
}
@JsonSerializable()
class GithubMirror {
GithubMirrorType type;
String url;
bool isBuiltIn;
bool isDeleted;
GithubMirror({
required this.type,
required this.url,
this.isBuiltIn = false,
this.isDeleted = false,
});
factory GithubMirror.fromJson(Map<String, dynamic> json) =>
_$GithubMirrorFromJson(json);
Map<String, dynamic> toJson() => _$GithubMirrorToJson(this);
}
@JsonSerializable(explicitToJson: true)
class ExtraConfigGithubMirror {
bool enabled;
List<GithubMirror> mirrors;
ExtraConfigGithubMirror({
this.enabled = true,
this.mirrors = const [],
});
factory ExtraConfigGithubMirror.fromJson(Map<String, dynamic>? json) =>
json == null
? ExtraConfigGithubMirror()
: _$ExtraConfigGithubMirrorFromJson(json);
Map<String, dynamic> toJson() => _$ExtraConfigGithubMirrorToJson(this);
}
@@ -14,7 +14,9 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
..protocolConfig = ProtocolConfig.fromJson( ..protocolConfig = ProtocolConfig.fromJson(
json['protocolConfig'] as Map<String, dynamic>?) json['protocolConfig'] as Map<String, dynamic>?)
..extra = ExtraConfig.fromJson(json['extra'] as Map<String, dynamic>?) ..extra = ExtraConfig.fromJson(json['extra'] as Map<String, dynamic>?)
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>); ..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)
..webhook =
WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?);
Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) => Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -23,6 +25,7 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) =>
'protocolConfig': instance.protocolConfig.toJson(), 'protocolConfig': instance.protocolConfig.toJson(),
'extra': instance.extra.toJson(), 'extra': instance.extra.toJson(),
'proxy': instance.proxy.toJson(), 'proxy': instance.proxy.toJson(),
'webhook': instance.webhook.toJson(),
}; };
ProtocolConfig _$ProtocolConfigFromJson(Map<String, dynamic> json) => ProtocolConfig _$ProtocolConfigFromJson(Map<String, dynamic> json) =>
@@ -75,7 +78,14 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
defaultDirectDownload: json['defaultDirectDownload'] as bool? ?? false, defaultDirectDownload: json['defaultDirectDownload'] as bool? ?? false,
defaultBtClient: json['defaultBtClient'] as bool? ?? true, defaultBtClient: json['defaultBtClient'] as bool? ?? true,
notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true, notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
)..bt = ExtraConfigBt.fromJson(json['bt'] as Map<String, dynamic>); downloadCategories: (json['downloadCategories'] as List<dynamic>?)
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
)
..bt = ExtraConfigBt.fromJson(json['bt'] as Map<String, dynamic>)
..githubMirror = ExtraConfigGithubMirror.fromJson(
json['githubMirror'] as Map<String, dynamic>?);
Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) => Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -85,7 +95,51 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
'defaultDirectDownload': instance.defaultDirectDownload, 'defaultDirectDownload': instance.defaultDirectDownload,
'defaultBtClient': instance.defaultBtClient, 'defaultBtClient': instance.defaultBtClient,
'notifyWhenNewVersion': instance.notifyWhenNewVersion, 'notifyWhenNewVersion': instance.notifyWhenNewVersion,
'downloadCategories':
instance.downloadCategories.map((e) => e.toJson()).toList(),
'bt': instance.bt.toJson(), 'bt': instance.bt.toJson(),
'githubMirror': instance.githubMirror.toJson(),
};
DownloadCategory _$DownloadCategoryFromJson(Map<String, dynamic> json) =>
DownloadCategory(
name: json['name'] as String,
path: json['path'] as String,
isBuiltIn: json['isBuiltIn'] as bool? ?? false,
nameKey: json['nameKey'] as String?,
isDeleted: json['isDeleted'] as bool? ?? false,
);
Map<String, dynamic> _$DownloadCategoryToJson(DownloadCategory instance) {
final val = <String, dynamic>{
'name': instance.name,
'path': instance.path,
'isBuiltIn': instance.isBuiltIn,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('nameKey', instance.nameKey);
val['isDeleted'] = instance.isDeleted;
return val;
}
WebhookConfig _$WebhookConfigFromJson(Map<String, dynamic> json) =>
WebhookConfig(
enable: json['enable'] as bool? ?? false,
urls:
(json['urls'] as List<dynamic>?)?.map((e) => e as String).toList() ??
const [],
);
Map<String, dynamic> _$WebhookConfigToJson(WebhookConfig instance) =>
<String, dynamic>{
'enable': instance.enable,
'urls': instance.urls,
}; };
ProxyConfig _$ProxyConfigFromJson(Map<String, dynamic> json) => ProxyConfig( ProxyConfig _$ProxyConfigFromJson(Map<String, dynamic> json) => ProxyConfig(
@@ -141,3 +195,40 @@ Map<String, dynamic> _$ExtraConfigBtToJson(ExtraConfigBt instance) {
val['customTrackers'] = instance.customTrackers; val['customTrackers'] = instance.customTrackers;
return val; return val;
} }
GithubMirror _$GithubMirrorFromJson(Map<String, dynamic> json) => GithubMirror(
type: $enumDecode(_$GithubMirrorTypeEnumMap, json['type']),
url: json['url'] as String,
isBuiltIn: json['isBuiltIn'] as bool? ?? false,
isDeleted: json['isDeleted'] as bool? ?? false,
);
Map<String, dynamic> _$GithubMirrorToJson(GithubMirror instance) =>
<String, dynamic>{
'type': _$GithubMirrorTypeEnumMap[instance.type]!,
'url': instance.url,
'isBuiltIn': instance.isBuiltIn,
'isDeleted': instance.isDeleted,
};
const _$GithubMirrorTypeEnumMap = {
GithubMirrorType.jsdelivr: 'jsdelivr',
GithubMirrorType.ghProxy: 'ghProxy',
};
ExtraConfigGithubMirror _$ExtraConfigGithubMirrorFromJson(
Map<String, dynamic> json) =>
ExtraConfigGithubMirror(
enabled: json['enabled'] as bool? ?? true,
mirrors: (json['mirrors'] as List<dynamic>?)
?.map((e) => GithubMirror.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
);
Map<String, dynamic> _$ExtraConfigGithubMirrorToJson(
ExtraConfigGithubMirror instance) =>
<String, dynamic>{
'enabled': instance.enabled,
'mirrors': instance.mirrors.map((e) => e.toJson()).toList(),
};
@@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:launch_at_startup/launch_at_startup.dart'; import 'package:launch_at_startup/launch_at_startup.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:share_handler/share_handler.dart'; import 'package:share_handler/share_handler.dart';
import 'package:tray_manager/tray_manager.dart'; import 'package:tray_manager/tray_manager.dart';
@@ -504,6 +505,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
// default select all tracker subscribe urls // default select all tracker subscribe urls
extra.bt.trackerSubscribeUrls.addAll(allTrackerSubscribeUrls); extra.bt.trackerSubscribeUrls.addAll(allTrackerSubscribeUrls);
} }
final proxy = config.proxy; final proxy = config.proxy;
if (proxy.scheme.isEmpty) { if (proxy.scheme.isEmpty) {
proxy.scheme = 'http'; proxy.scheme = 'http';
@@ -521,6 +523,68 @@ class AppController extends GetxController with WindowListener, TrayListener {
config.downloadDir = './'; config.downloadDir = './';
} }
} }
// Initialize default download categories if empty
if (extra.downloadCategories.isEmpty) {
_initDefaultDownloadCategories();
}
// Initialize default GitHub mirrors if empty
if (extra.githubMirror.mirrors.isEmpty) {
_initDefaultGithubMirrors();
}
}
void _initDefaultDownloadCategories() {
final extra = downloaderConfig.value.extra;
final downloadDir = downloaderConfig.value.downloadDir;
// Add default built-in categories with i18n keys
// No need to set initial name value, it will be retrieved via nameKey
extra.downloadCategories = [
DownloadCategory(
name: '',
path: path.join(downloadDir, 'Music'),
isBuiltIn: true,
nameKey: 'categoryMusic',
),
DownloadCategory(
name: '',
path: path.join(downloadDir, 'Video'),
isBuiltIn: true,
nameKey: 'categoryVideo',
),
DownloadCategory(
name: '',
path: path.join(downloadDir, 'Document'),
isBuiltIn: true,
nameKey: 'categoryDocument',
),
DownloadCategory(
name: '',
path: path.join(downloadDir, 'Program'),
isBuiltIn: true,
nameKey: 'categoryProgram',
),
];
}
void _initDefaultGithubMirrors() {
final extra = downloaderConfig.value.extra;
// Add default built-in GitHub mirrors
extra.githubMirror.mirrors = [
GithubMirror(
type: GithubMirrorType.jsdelivr,
url: 'https://fastly.jsdelivr.net/gh',
isBuiltIn: true,
),
GithubMirror(
type: GithubMirrorType.ghProxy,
url: 'https://fastgit.cc',
isBuiltIn: true,
),
];
} }
Future<void> _initLaunchAtStartup() async { Future<void> _initLaunchAtStartup() async {
@@ -12,6 +12,7 @@ import 'package:rounded_loading_button_plus/rounded_loading_button.dart';
import '../../../../api/api.dart'; import '../../../../api/api.dart';
import '../../../../api/model/create_task.dart'; import '../../../../api/model/create_task.dart';
import '../../../../api/model/create_task_batch.dart'; import '../../../../api/model/create_task_batch.dart';
import '../../../../api/model/downloader_config.dart';
import '../../../../api/model/options.dart'; import '../../../../api/model/options.dart';
import '../../../../api/model/request.dart'; import '../../../../api/model/request.dart';
import '../../../../api/model/resolve_result.dart'; import '../../../../api/model/resolve_result.dart';
@@ -72,7 +73,9 @@ class CreateView extends GetView<CreateController> {
.toString(); .toString();
} }
if (_pathController.text.isEmpty) { if (_pathController.text.isEmpty) {
_pathController.text = appController.downloaderConfig.value.downloadDir; // Render placeholders when initializing the path
final downloadDir = appController.downloaderConfig.value.downloadDir;
_pathController.text = renderPathPlaceholders(downloadDir);
} }
final CreateTask? routerParams = Get.rootDelegate.arguments(); final CreateTask? routerParams = Get.rootDelegate.arguments();
@@ -328,6 +331,8 @@ class CreateView extends GetView<CreateController> {
DirectorySelector( DirectorySelector(
controller: _pathController, controller: _pathController,
), ),
// Category selector
_buildCategorySelector(appController),
Obx( Obx(
() => Visibility( () => Visibility(
visible: controller.showAdvanced.value, visible: controller.showAdvanced.value,
@@ -912,4 +917,66 @@ class CreateView extends GetView<CreateController> {
], ],
)); ));
} }
Widget _buildCategorySelector(AppController appController) {
final categories =
appController.downloaderConfig.value.extra.downloadCategories
.where((c) => !c.isDeleted) // Filter out deleted categories
.toList();
if (categories.isEmpty) {
return const SizedBox.shrink();
}
// Helper to get display name
String getCategoryDisplayName(DownloadCategory category) {
if (category.nameKey != null && category.nameKey!.isNotEmpty) {
return category.nameKey!.tr;
}
return category.name;
}
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Text(
'selectCategory'.tr,
style: TextStyle(
color: Get.theme.hintColor,
fontSize: 12,
),
),
const SizedBox(width: 8),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: categories.map((category) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: OutlinedButton(
onPressed: () {
_pathController.text = renderPathPlaceholders(category.path);
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
minimumSize: Size.zero,
),
child: Text(
getCategoryDisplayName(category),
style: const TextStyle(fontSize: 12),
),
),
);
}).toList(),
),
),
),
],
),
);
}
} }
@@ -10,6 +10,7 @@ import 'package:intl/intl.dart';
import 'package:launch_at_startup/launch_at_startup.dart'; import 'package:launch_at_startup/launch_at_startup.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../../../../api/api.dart' as api;
import '../../../../api/model/downloader_config.dart'; import '../../../../api/model/downloader_config.dart';
import '../../../../i18n/message.dart'; import '../../../../i18n/message.dart';
import '../../../../util/input_formatter.dart'; import '../../../../util/input_formatter.dart';
@@ -24,6 +25,7 @@ import '../../../views/check_list_view.dart';
import '../../../views/directory_selector.dart'; import '../../../views/directory_selector.dart';
import '../../../views/open_in_new.dart'; import '../../../views/open_in_new.dart';
import '../../../views/outlined_button_loading.dart'; import '../../../views/outlined_button_loading.dart';
import '../../../views/text_button_loading.dart';
import '../../app/controllers/app_controller.dart'; import '../../app/controllers/app_controller.dart';
import '../controllers/setting_controller.dart'; import '../controllers/setting_controller.dart';
@@ -33,6 +35,14 @@ final _divider = const Divider().paddingOnly(left: 10, right: 10);
class SettingView extends GetView<SettingController> { class SettingView extends GetView<SettingController> {
const SettingView({Key? key}) : super(key: key); const SettingView({Key? key}) : super(key: key);
// Helper function to get display name for a category
static String _getCategoryDisplayName(DownloadCategory category) {
if (category.nameKey != null && category.nameKey!.isNotEmpty) {
return category.nameKey!.tr;
}
return category.name;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appController = Get.find<AppController>(); final appController = Get.find<AppController>();
@@ -69,20 +79,25 @@ class SettingView extends GetView<SettingController> {
'downloadDir', () => downloaderCfg.value.downloadDir, (Key key) { 'downloadDir', () => downloaderCfg.value.downloadDir, (Key key) {
final downloadDirController = final downloadDirController =
TextEditingController(text: downloaderCfg.value.downloadDir); TextEditingController(text: downloaderCfg.value.downloadDir);
downloadDirController.addListener(() async {
// Update config only when editing is done (on focus lost or submit)
void onEditComplete() {
if (downloadDirController.text != downloaderCfg.value.downloadDir) { if (downloadDirController.text != downloaderCfg.value.downloadDir) {
downloaderCfg.value.downloadDir = downloadDirController.text; downloaderCfg.value.downloadDir = downloadDirController.text;
if (Util.isDesktop()) { if (Util.isDesktop()) {
controller.clearTap(); controller.clearTap();
} }
debounceSave();
await debounceSave();
} }
}); }
return DirectorySelector( return DirectorySelector(
controller: downloadDirController, controller: downloadDirController,
showLabel: false, showLabel: false,
showAndoirdToggle: true, showAndoirdToggle: true,
allowEdit: true,
showPlaceholderButton: true,
onEditComplete: onEditComplete,
); );
}); });
final buildMaxRunning = _buildConfigItem( final buildMaxRunning = _buildConfigItem(
@@ -133,6 +148,133 @@ class SettingView extends GetView<SettingController> {
); );
}); });
// Download categories configuration
buildDownloadCategories() {
final categories = downloaderCfg.value.extra.downloadCategories
.where((c) => !c.isDeleted) // Filter out deleted categories
.toList();
return ListTile(
title: Text('downloadCategories'.tr),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (categories.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'add'.tr,
style: TextStyle(color: Theme.of(context).hintColor),
),
),
...categories.map((category) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_getCategoryDisplayName(category),
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
category.path,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).hintColor,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
IconButton(
icon: Icon(
Icons.edit,
size: 20,
color: Theme.of(context).hintColor,
),
onPressed: () {
_showCategoryDialog(
context,
debounceSave,
downloaderCfg,
category: category,
);
},
),
IconButton(
icon: Icon(
Icons.delete,
size: 20,
color: Theme.of(context).hintColor,
),
onPressed: () async {
// Show confirmation dialog
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('tip'.tr),
content: Text('confirmDelete'.tr),
actions: [
TextButton(
onPressed: () =>
Navigator.of(context).pop(false),
child: Text('cancel'.tr),
),
TextButton(
onPressed: () =>
Navigator.of(context).pop(true),
child: Text('confirm'.tr),
),
],
),
);
if (confirmed == true) {
if (category.isBuiltIn) {
// Mark built-in category as deleted instead of removing it
downloaderCfg.update((val) {
category.isDeleted = true;
});
} else {
// Remove custom categories completely
downloaderCfg.update((val) {
val!.extra.downloadCategories = val
.extra.downloadCategories
.where((c) => c != category)
.toList();
});
}
debounceSave();
}
},
),
],
),
);
}),
Padding(
padding: const EdgeInsets.only(top: 8),
child: OutlinedButton.icon(
icon: const Icon(Icons.add, size: 18),
label: Text('add'.tr),
onPressed: () {
_showCategoryDialog(
context,
debounceSave,
downloaderCfg,
);
},
),
),
],
),
);
}
buildBrowserExtension() { buildBrowserExtension() {
return ListTile( return ListTile(
title: Text('browserExtension'.tr), title: Text('browserExtension'.tr),
@@ -687,6 +829,19 @@ class SettingView extends GetView<SettingController> {
final usrController = TextEditingController(text: proxy.usr); final usrController = TextEditingController(text: proxy.usr);
final pwdController = TextEditingController(text: proxy.pwd); final pwdController = TextEditingController(text: proxy.pwd);
updateAuth() async {
if (usrController.text != proxy.usr ||
pwdController.text != proxy.pwd) {
proxy.usr = usrController.text;
proxy.pwd = pwdController.text;
await debounceSave();
}
}
usrController.addListener(updateAuth);
pwdController.addListener(updateAuth);
final auth = Row(children: [ final auth = Row(children: [
Flexible( Flexible(
child: TextFormField( child: TextFormField(
@@ -705,6 +860,7 @@ class SettingView extends GetView<SettingController> {
labelText: 'password'.tr, labelText: 'password'.tr,
contentPadding: const EdgeInsets.all(0.0), contentPadding: const EdgeInsets.all(0.0),
), ),
obscureText: true,
), ),
), ),
]); ]);
@@ -728,6 +884,136 @@ class SettingView extends GetView<SettingController> {
}, },
); );
// advanced config GitHub mirror items start
final buildGithubMirror = _buildConfigItem(
'githubMirror',
() => downloaderCfg.value.extra.githubMirror.enabled ? 'on'.tr : 'off'.tr,
(Key key) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'githubMirrorEnable'.tr,
style: Theme.of(Get.context!).textTheme.bodyMedium,
),
const Spacer(),
Switch(
value: downloaderCfg.value.extra.githubMirror.enabled,
onChanged: (value) {
downloaderCfg.update((val) {
val!.extra.githubMirror.enabled = value;
});
debounceSave();
},
),
],
),
_padding,
Text(
'githubMirrorDesc'.tr,
style: Theme.of(Get.context!).textTheme.bodySmall,
),
_padding,
// List of existing GitHub mirrors
...downloaderCfg.value.extra.githubMirror.mirrors
.where((m) => !m.isDeleted)
.toList()
.asMap()
.entries
.map((entry) {
// Get the original index in the full list
final mirror = entry.value;
final index = downloaderCfg.value.extra.githubMirror.mirrors
.indexOf(mirror);
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Expanded(
child: Row(
children: [
Expanded(
child: Text(
mirror.url,
style:
Theme.of(Get.context!).textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.edit, size: 20),
tooltip: 'edit'.tr,
onPressed: () {
_showGithubMirrorDialog(
index: index, initialMirror: mirror);
},
),
IconButton(
icon: const Icon(Icons.delete, size: 20),
tooltip: 'delete'.tr,
onPressed: () async {
// Show confirmation dialog
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('tip'.tr),
content: Text('confirmDelete'.tr),
actions: [
TextButton(
onPressed: () =>
Navigator.of(context).pop(false),
child: Text('cancel'.tr),
),
TextButton(
onPressed: () =>
Navigator.of(context).pop(true),
child: Text('confirm'.tr),
),
],
),
);
if (confirmed != true) return;
if (mirror.isBuiltIn) {
// Mark built-in mirror as deleted (logical delete)
downloaderCfg.update((val) {
mirror.isDeleted = true;
});
} else {
// Remove custom mirrors completely (physical delete)
final mirrors = List<GithubMirror>.from(
downloaderCfg.value.extra.githubMirror.mirrors);
mirrors.removeAt(index);
downloaderCfg.update((val) {
val!.extra.githubMirror.mirrors = mirrors;
});
}
await debounceSave();
},
),
],
),
);
}),
_padding,
// Add button
OutlinedButton.icon(
onPressed: () {
_showGithubMirrorDialog();
},
icon: const Icon(Icons.add),
label: Text('add'.tr),
),
],
);
},
);
// advanced config API items start // advanced config API items start
final buildApiProtocol = _buildConfigItem( final buildApiProtocol = _buildConfigItem(
'protocol', 'protocol',
@@ -877,6 +1163,92 @@ class SettingView extends GetView<SettingController> {
); );
}); });
// advanced config webhook items
final buildWebhook = _buildConfigItem(
'webhook',
() => downloaderCfg.value.webhook.enable ? 'on'.tr : 'off'.tr,
(Key key) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'webhookEnable'.tr,
style: Theme.of(Get.context!).textTheme.bodyMedium,
),
const Spacer(),
Switch(
value: downloaderCfg.value.webhook.enable,
onChanged: (value) {
downloaderCfg.update((val) {
val!.webhook.enable = value;
});
debounceSave();
},
),
],
),
_padding,
Text(
'webhookDesc'.tr,
style: Theme.of(Get.context!).textTheme.bodySmall,
),
_padding,
// List of existing webhook URLs
...downloaderCfg.value.webhook.urls.asMap().entries.map((entry) {
final index = entry.key;
final url = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Expanded(
child: Text(
url,
style: Theme.of(Get.context!).textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.edit, size: 20),
tooltip: 'edit'.tr,
onPressed: () {
_showWebhookDialog(index: index, initialUrl: url);
},
),
IconButton(
icon: const Icon(Icons.delete, size: 20),
tooltip: 'delete'.tr,
onPressed: () async {
// Create new list to avoid unmodifiable list error
final urls =
List<String>.from(downloaderCfg.value.webhook.urls);
urls.removeAt(index);
downloaderCfg.update((val) {
val!.webhook.urls = urls;
});
await debounceSave();
},
),
],
),
);
}),
_padding,
// Add button
OutlinedButton.icon(
onPressed: () {
_showWebhookDialog();
},
icon: const Icon(Icons.add),
label: Text('add'.tr),
),
],
);
},
);
// advanced config log items start // advanced config log items start
buildLogsDir() { buildLogsDir() {
return ListTile( return ListTile(
@@ -936,6 +1308,7 @@ class SettingView extends GetView<SettingController> {
child: Column( child: Column(
children: _addDivider([ children: _addDivider([
buildDownloadDir(), buildDownloadDir(),
buildDownloadCategories(),
buildMaxRunning(), buildMaxRunning(),
buildDefaultDirectDownload(), buildDefaultDirectDownload(),
buildBrowserExtension(), buildBrowserExtension(),
@@ -1005,7 +1378,10 @@ class SettingView extends GetView<SettingController> {
Text('network'.tr), Text('network'.tr),
Card( Card(
child: Column( child: Column(
children: _addDivider([buildProxy()]), children: _addDivider([
buildProxy(),
buildGithubMirror(),
]),
)), )),
const Text('API'), const Text('API'),
Card( Card(
@@ -1021,6 +1397,7 @@ class SettingView extends GetView<SettingController> {
Card( Card(
child: Column( child: Column(
children: _addDivider([ children: _addDivider([
buildWebhook(),
buildLogsDir(), buildLogsDir(),
]), ]),
)), )),
@@ -1033,6 +1410,244 @@ class SettingView extends GetView<SettingController> {
}); });
} }
void _showWebhookDialog({int? index, String? initialUrl}) {
final urlController = TextEditingController(text: initialUrl ?? '');
final testController = OutlinedButtonLoadingController();
final saveController = TextButtonLoadingController();
final appController = Get.find<AppController>();
final downloaderCfg = appController.downloaderConfig;
final isEdit = index != null;
final formKey = GlobalKey<FormState>();
showDialog(
context: Get.context!,
builder: (dialogContext) => AlertDialog(
title: Text(isEdit ? 'edit'.tr : 'add'.tr),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: urlController,
decoration: InputDecoration(
hintText: 'webhookUrlHint'.tr,
),
keyboardType: TextInputType.url,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'required'.tr;
}
final url = value.trim().toLowerCase();
if (!url.startsWith('http://') &&
!url.startsWith('https://')) {
return 'urlInvalid'.tr;
}
try {
Uri.parse(value.trim());
} catch (e) {
return 'urlInvalid'.tr;
}
return null;
},
),
],
),
),
actionsAlignment: MainAxisAlignment.spaceBetween,
actions: [
// Test button on the left
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: OutlinedButtonLoading(
controller: testController,
onPressed: () async {
if (!formKey.currentState!.validate()) {
return;
}
final url = urlController.text.trim();
if (url.isEmpty) return;
testController.start();
try {
await api.testWebhook(url);
showMessage('tip'.tr, 'webhookTestSuccess'.tr);
} catch (e) {
showErrorMessage('webhookTestFail'.tr);
} finally {
testController.stop();
}
},
child: Text('webhookTest'.tr),
),
),
// Cancel and Confirm on the right
Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text('cancel'.tr),
),
TextButtonLoading(
controller: saveController,
onPressed: () async {
if (!formKey.currentState!.validate()) {
return;
}
final url = urlController.text.trim();
if (url.isEmpty) return;
saveController.start();
try {
// Create new list to avoid unmodifiable list error
final urls =
List<String>.from(downloaderCfg.value.webhook.urls);
if (isEdit) {
urls[index] = url;
} else {
urls.add(url);
}
downloaderCfg.update((val) {
val!.webhook.urls = urls;
});
await appController.saveConfig();
if (dialogContext.mounted) {
Navigator.of(dialogContext).pop();
}
} catch (e) {
showErrorMessage(e);
} finally {
saveController.stop();
}
},
child: Text('confirm'.tr),
),
],
),
],
),
);
}
void _showGithubMirrorDialog({int? index, GithubMirror? initialMirror}) {
final isEdit = index != null;
GithubMirrorType selectedType =
initialMirror?.type ?? GithubMirrorType.jsdelivr;
final urlController = TextEditingController(text: initialMirror?.url ?? '');
final saveController = TextButtonLoadingController();
final appController = Get.find<AppController>();
final downloaderCfg = appController.downloaderConfig;
final formKey = GlobalKey<FormState>();
showDialog(
context: Get.context!,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Text(isEdit ? 'edit'.tr : 'add'.tr),
content: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<GithubMirrorType>(
value: selectedType,
decoration: InputDecoration(
labelText: 'githubMirrorType'.tr,
),
onChanged: (value) {
if (value != null) {
setState(() {
selectedType = value;
});
}
},
items: GithubMirrorType.values
.map((type) => DropdownMenuItem(
value: type,
child: Text(type.name),
))
.toList(),
),
const SizedBox(height: 16),
TextFormField(
controller: urlController,
decoration: InputDecoration(
labelText: 'githubMirrorUrl'.tr,
hintText: 'githubMirrorUrlHint'.tr,
),
keyboardType: TextInputType.url,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'required'.tr;
}
final url = value.trim().toLowerCase();
if (!url.startsWith('http://') &&
!url.startsWith('https://')) {
return 'urlInvalid'.tr;
}
try {
Uri.parse(value.trim());
} catch (e) {
return 'urlInvalid'.tr;
}
return null;
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text('cancel'.tr),
),
TextButtonLoading(
controller: saveController,
onPressed: () async {
if (!formKey.currentState!.validate()) {
return;
}
final url = urlController.text.trim();
if (url.isEmpty) return;
saveController.start();
try {
// Create new list to avoid unmodifiable list error
final mirrors = List<GithubMirror>.from(
downloaderCfg.value.extra.githubMirror.mirrors);
final newMirror = GithubMirror(
type: selectedType,
url: url,
);
if (isEdit) {
mirrors[index] = newMirror;
} else {
mirrors.add(newMirror);
}
downloaderCfg.update((val) {
val!.extra.githubMirror.mirrors = mirrors;
});
await appController.saveConfig();
if (dialogContext.mounted) {
Navigator.of(dialogContext).pop();
}
} catch (e) {
showErrorMessage(e);
} finally {
saveController.stop();
}
},
child: Text('confirm'.tr),
),
],
),
),
);
}
void _tapInputWidget(GlobalKey key) { void _tapInputWidget(GlobalKey key) {
if (key.currentContext == null) { if (key.currentContext == null) {
return; return;
@@ -1105,6 +1720,88 @@ class SettingView extends GetView<SettingController> {
return 'themeSystem'.tr; return 'themeSystem'.tr;
} }
} }
void _showCategoryDialog(
BuildContext context,
Future<bool> Function() debounceSave,
Rx<DownloaderConfig> downloaderCfg, {
DownloadCategory? category,
}) {
final isEdit = category != null;
final nameController = TextEditingController(
text: isEdit ? _getCategoryDisplayName(category) : '',
);
final pathController = TextEditingController(text: category?.path ?? '');
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(isEdit ? 'edit'.tr : 'add'.tr),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: InputDecoration(
labelText: 'categoryName'.tr,
),
),
const SizedBox(height: 16),
DirectorySelector(
controller: pathController,
showLabel: true,
allowEdit: true,
showPlaceholderButton: true,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('cancel'.tr),
),
TextButton(
onPressed: () {
if (nameController.text.isEmpty || pathController.text.isEmpty) {
return;
}
if (isEdit) {
// Trigger UI update by wrapping changes in update()
downloaderCfg.update((val) {
// If name changed, clear nameKey so it won't be re-translated
final nameChanged =
nameController.text != _getCategoryDisplayName(category);
category.name = nameController.text;
category.path = pathController.text;
if (nameChanged) {
category.nameKey = null;
}
// If editing a deleted built-in category, unmark it as deleted
if (category.isBuiltIn && category.isDeleted) {
category.isDeleted = false;
}
});
} else {
downloaderCfg.update((val) {
val!.extra.downloadCategories = [
...val.extra.downloadCategories,
DownloadCategory(
name: nameController.text,
path: pathController.text,
),
];
});
}
debounceSave();
Navigator.of(context).pop();
},
child: Text('confirm'.tr),
),
],
),
);
}
} }
enum ProxyModeEnum { enum ProxyModeEnum {
+181 -17
View File
@@ -14,17 +14,86 @@ import '../../util/util.dart';
final deviceInfo = DeviceInfoPlugin(); final deviceInfo = DeviceInfoPlugin();
// Placeholder information for download directory
class PathPlaceholder {
final String placeholder;
final String description;
final String example;
const PathPlaceholder({
required this.placeholder,
required this.description,
required this.example,
});
}
// Available placeholders for download directory
List<PathPlaceholder> getPathPlaceholders() {
final now = DateTime.now();
final year = now.year.toString();
final month = now.month.toString().padLeft(2, '0');
final day = now.day.toString().padLeft(2, '0');
return [
PathPlaceholder(
placeholder: '%year%',
description: 'placeholderYear'.tr,
example: year,
),
PathPlaceholder(
placeholder: '%month%',
description: 'placeholderMonth'.tr,
example: month,
),
PathPlaceholder(
placeholder: '%day%',
description: 'placeholderDay'.tr,
example: day,
),
PathPlaceholder(
placeholder: '%date%',
description: 'placeholderDate'.tr,
example: '$year-$month-$day',
),
];
}
// Render placeholders in a path with actual values
String renderPathPlaceholders(String path) {
if (path.isEmpty) return path;
final now = DateTime.now();
final year = now.year.toString();
final month = now.month.toString().padLeft(2, '0');
final day = now.day.toString().padLeft(2, '0');
final date = '$year-$month-$day';
return path
.replaceAll('%year%', year)
.replaceAll('%month%', month)
.replaceAll('%day%', day)
.replaceAll('%date%', date);
}
class DirectorySelector extends StatefulWidget { class DirectorySelector extends StatefulWidget {
final TextEditingController controller; final TextEditingController controller;
final bool showLabel; final bool showLabel;
final bool showAndoirdToggle; final bool showAndoirdToggle;
final bool allowEdit;
final bool showPlaceholderButton;
final VoidCallback? onEditComplete;
final bool showRenderedPlaceholders;
const DirectorySelector( const DirectorySelector({
{Key? key, Key? key,
required this.controller, required this.controller,
this.showLabel = true, this.showLabel = true,
this.showAndoirdToggle = false}) this.showAndoirdToggle = false,
: super(key: key); this.allowEdit = false,
this.showPlaceholderButton = false,
this.onEditComplete,
this.showRenderedPlaceholders = false,
}) : super(key: key);
@override @override
State<DirectorySelector> createState() => _DirectorySelectorState(); State<DirectorySelector> createState() => _DirectorySelectorState();
@@ -108,22 +177,117 @@ class _DirectorySelectorState extends State<DirectorySelector> {
return null; return null;
} }
Widget? buildPlaceholderButton() {
if (!widget.showPlaceholderButton) return null;
return PopupMenuButton<String>(
icon: const Icon(Icons.data_object),
tooltip: 'insertPlaceholder'.tr,
onSelected: (String placeholder) {
final currentText = widget.controller.text;
final selection = widget.controller.selection;
final cursorPosition = selection.baseOffset >= 0
? selection.baseOffset
: currentText.length;
final newText = currentText.substring(0, cursorPosition) +
placeholder +
currentText.substring(cursorPosition);
widget.controller.text = newText;
widget.controller.selection = TextSelection.fromPosition(
TextPosition(offset: cursorPosition + placeholder.length),
);
},
itemBuilder: (BuildContext context) {
final placeholders = getPathPlaceholders();
return placeholders.map((p) {
return PopupMenuItem<String>(
value: p.placeholder,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${p.placeholder} - ${p.description}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
'example'.trParams({'value': p.example}),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).hintColor,
),
),
],
),
);
}).toList();
},
);
}
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: TextFormField( child: ValueListenableBuilder<TextEditingValue>(
readOnly: Util.isWeb() ? false : true, valueListenable: widget.controller,
controller: widget.controller, builder: (context, value, child) {
decoration: widget.showLabel Widget? suffix;
? InputDecoration( if (widget.showRenderedPlaceholders && value.text.contains('%')) {
labelText: 'downloadDir'.tr, final renderedPath = renderPathPlaceholders(value.text);
) // Show rendered path as a chip/badge in the input field
: null, suffix = Container(
validator: (v) { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
return v!.trim().isNotEmpty ? null : 'downloadDirValid'.tr; margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: Colors.blue[50],
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.blue[200]!),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.arrow_forward, size: 14, color: Colors.blue[700]),
const SizedBox(width: 4),
Flexible(
child: Text(
renderedPath,
style: TextStyle(
color: Colors.blue[700],
fontSize: 12,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
return TextFormField(
readOnly: widget.allowEdit ? false : (Util.isWeb() ? false : true),
controller: widget.controller,
decoration: widget.showLabel
? InputDecoration(
labelText: 'downloadDir'.tr,
suffix: suffix,
)
: InputDecoration(
suffix: suffix,
),
validator: (v) {
return v!.trim().isNotEmpty ? null : 'downloadDirValid'.tr;
},
onEditingComplete: widget.onEditComplete,
onTapOutside: (event) {
// Call onEditComplete when user taps outside the field
widget.onEditComplete?.call();
},
);
}, },
)), )),
buildSelectWidget() buildSelectWidget(),
buildPlaceholderButton(),
].where((e) => e != null).map((e) => e!).toList(), ].where((e) => e != null).map((e) => e!).toList(),
); );
} }
+26 -5
View File
@@ -4,6 +4,7 @@ const deDE = {
'error': 'Fehler', 'error': 'Fehler',
'tip': 'Tipp', 'tip': 'Tipp',
'confirm': 'Bestätigen', 'confirm': 'Bestätigen',
'confirmDelete': 'Möchten Sie wirklich löschen?',
'cancel': 'Abbrechen', 'cancel': 'Abbrechen',
'on': 'An', 'on': 'An',
'off': 'Aus', 'off': 'Aus',
@@ -21,10 +22,13 @@ const deDE = {
'followSettings': 'Einstellungen Folgen', 'followSettings': 'Einstellungen Folgen',
'downloadLink': 'Download Link', 'downloadLink': 'Download Link',
'downloadLinkValid': 'Bitte geben Sie den Download-Link ein', 'downloadLinkValid': 'Bitte geben Sie den Download-Link ein',
'downloadLinkHit': 'Bitte geben Sie den Download-Link ein, einen pro Zeile@append', 'downloadLinkHit':
'downloadLinkHitDesktop': ', oder ziehen Sie die Torrent-Datei direkt hierher', 'Bitte geben Sie den Download-Link ein, einen pro Zeile@append',
'downloadLinkHitDesktop':
', oder ziehen Sie die Torrent-Datei direkt hierher',
'download': 'Download', 'download': 'Download',
'noFileSelected': 'Bitte wählen Sie mindestens eine Datei aus, um fortzufahren.', 'noFileSelected':
'Bitte wählen Sie mindestens eine Datei aus, um fortzufahren.',
'noStoragePermission': 'Speicherberechtigung erforderlich', 'noStoragePermission': 'Speicherberechtigung erforderlich',
'selectFile': 'Datei auswählen', 'selectFile': 'Datei auswählen',
'rename': 'Umbenennen', 'rename': 'Umbenennen',
@@ -45,7 +49,8 @@ const deDE = {
'updateDaily': 'Tägliche Aktualisierung', 'updateDaily': 'Tägliche Aktualisierung',
'lastUpdate': 'Letzte Aktualisierung: @time', 'lastUpdate': 'Letzte Aktualisierung: @time',
'addTracker': 'Tracker hinzufügen', 'addTracker': 'Tracker hinzufügen',
'addTrackerHit': 'Bitte geben Sie die URL des Tracker-Servers ein, eine pro Zeile', 'addTrackerHit':
'Bitte geben Sie die URL des Tracker-Servers ein, eine pro Zeile',
'ui': 'Benutzeroberfläche', 'ui': 'Benutzeroberfläche',
'theme': 'Thema', 'theme': 'Thema',
'themeSystem': 'System', 'themeSystem': 'System',
@@ -122,8 +127,24 @@ const deDE = {
'username_required': 'Bitte geben Sie Ihren Benutzernamen ein', 'username_required': 'Bitte geben Sie Ihren Benutzernamen ein',
'password_required': 'Bitte geben Sie Ihr Passwort ein', 'password_required': 'Bitte geben Sie Ihr Passwort ein',
'login_success': 'Anmeldung erfolgreich', 'login_success': 'Anmeldung erfolgreich',
'login_failed': 'Anmeldung fehlgeschlagen, bitte überprüfen Sie Ihren Benutzernamen und Ihr Passwort', 'login_failed':
'Anmeldung fehlgeschlagen, bitte überprüfen Sie Ihren Benutzernamen und Ihr Passwort',
'login_failed_network': 'login_failed_network':
'Anmeldung fehlgeschlagen, bitte überprüfen Sie Ihre Netzwerkverbindung', 'Anmeldung fehlgeschlagen, bitte überprüfen Sie Ihre Netzwerkverbindung',
'insertPlaceholder': 'Platzhalter einfügen',
'placeholderYear': 'Aktuelles Jahr',
'placeholderMonth': 'Aktueller Monat (01-12)',
'placeholderDay': 'Aktueller Tag (01-31)',
'placeholderDate': 'Vollständiges Datum (JJJJ-MM-TT)',
'example': 'z.B. @value',
'downloadCategories': 'Download-Kategorien',
'categoryMusic': 'Musik',
'categoryVideo': 'Video',
'categoryDocument': 'Dokument',
'categoryProgram': 'Programm',
'categoryName': 'Kategoriename',
'categoryPath': 'Kategoriepfad',
'builtInCategory': 'Integrierte Kategorie kann nicht gelöscht werden',
'selectCategory': 'Kategorie auswählen',
}, },
}; };
+33
View File
@@ -4,6 +4,7 @@ const enUS = {
'error': 'Error', 'error': 'Error',
'tip': 'Tip', 'tip': 'Tip',
'confirm': 'Confirm', 'confirm': 'Confirm',
'confirmDelete': 'Are you sure you want to delete?',
'cancel': 'Cancel', 'cancel': 'Cancel',
'on': 'On', 'on': 'On',
'off': 'Off', 'off': 'Off',
@@ -65,6 +66,15 @@ const enUS = {
'effectAfterRestart': 'Effect after restart', 'effectAfterRestart': 'Effect after restart',
'developer': 'Developer', 'developer': 'Developer',
'logDirectory': 'Log Directory', 'logDirectory': 'Log Directory',
'webhook': 'Webhook',
'webhookEnable': 'Enable Webhook',
'webhookDesc': 'Send HTTP POST notifications when tasks complete or fail',
'webhookUrlHint': 'Enter webhook URL',
'webhookTest': 'Test',
'webhookTestSuccess': 'Webhook test successful',
'webhookTestFail': 'Webhook test failed',
'urlInvalid': 'Please enter a valid HTTP or HTTPS URL',
'required': 'This field is required',
'show': 'Show', 'show': 'Show',
'continue': 'Continue', 'continue': 'Continue',
'pause': 'Pause', 'pause': 'Pause',
@@ -73,6 +83,8 @@ const enUS = {
'deleteTask': 'Delete @count tasks', 'deleteTask': 'Delete @count tasks',
'deleteTaskTip': 'Keep downloaded files', 'deleteTaskTip': 'Keep downloaded files',
'delete': 'Delete', 'delete': 'Delete',
'add': 'Add',
'edit': 'Edit',
'newVersionTitle': 'Discover new version @version', 'newVersionTitle': 'Discover new version @version',
'newVersionUpdate': 'Update Now', 'newVersionUpdate': 'Update Now',
'newVersionLater': 'Later', 'newVersionLater': 'Later',
@@ -126,5 +138,26 @@ const enUS = {
'login_failed': 'Login failed, please check your username and password', 'login_failed': 'Login failed, please check your username and password',
'login_failed_network': 'login_failed_network':
'Login failed, please check your network connection', 'Login failed, please check your network connection',
'insertPlaceholder': 'Insert Placeholder',
'placeholderYear': 'Current year',
'placeholderMonth': 'Current month (01-12)',
'placeholderDay': 'Current day (01-31)',
'placeholderDate': 'Full date (YYYY-MM-DD)',
'example': 'e.g. @value',
'downloadCategories': 'Download Categories',
'categoryMusic': 'Music',
'categoryVideo': 'Video',
'categoryDocument': 'Document',
'categoryProgram': 'Program',
'categoryName': 'Category Name',
'categoryPath': 'Category Path',
'builtInCategory': 'Built-in category cannot be deleted',
'selectCategory': 'Select Category',
'githubMirror': 'GitHub Mirror',
'githubMirrorEnable': 'Enable GitHub Mirror',
'githubMirrorDesc': 'Use mirrors to accelerate GitHub content downloads',
'githubMirrorType': 'Mirror Type',
'githubMirrorUrl': 'Mirror URL',
'githubMirrorUrlHint': 'Enter mirror URL',
}, },
}; };
+23 -4
View File
@@ -4,6 +4,7 @@ const esES = {
'error': 'Error', 'error': 'Error',
'tip': 'Consejo', 'tip': 'Consejo',
'confirm': 'Confirmar', 'confirm': 'Confirmar',
'confirmDelete': '¿Está seguro de que desea eliminar?',
'cancel': 'Cancelar', 'cancel': 'Cancelar',
'on': 'Activado', 'on': 'Activado',
'off': 'Desactivado', 'off': 'Desactivado',
@@ -21,9 +22,11 @@ const esES = {
'downloadLinkValid': 'Por favor, introduce el enlace de descarga', 'downloadLinkValid': 'Por favor, introduce el enlace de descarga',
'downloadLinkHit': 'downloadLinkHit':
'Por favor, introduce el enlace de descarga, se admite HTTP/HTTPS/MAGNET@append', 'Por favor, introduce el enlace de descarga, se admite HTTP/HTTPS/MAGNET@append',
'downloadLinkHitDesktop': ', o arrastra el archivo torrent aquí directamente', 'downloadLinkHitDesktop':
', o arrastra el archivo torrent aquí directamente',
'download': 'Descargar', 'download': 'Descargar',
'noFileSelected': 'Por favor, selecciona al menos un archivo para continuar.', 'noFileSelected':
'Por favor, selecciona al menos un archivo para continuar.',
'noStoragePermission': 'Se requiere permiso de almacenamiento', 'noStoragePermission': 'Se requiere permiso de almacenamiento',
'selectFile': 'Seleccionar Archivo', 'selectFile': 'Seleccionar Archivo',
'rename': 'Renombrar', 'rename': 'Renombrar',
@@ -43,7 +46,8 @@ const esES = {
'updateDaily': 'Actualizar diariamente', 'updateDaily': 'Actualizar diariamente',
'lastUpdate': 'Última actualización: @time', 'lastUpdate': 'Última actualización: @time',
'addTracker': 'Añadir Tracker', 'addTracker': 'Añadir Tracker',
'addTrackerHit': 'Por favor, introduce la URL del servidor tracker, una por línea', 'addTrackerHit':
'Por favor, introduce la URL del servidor tracker, una por línea',
'ui': 'Interfaz', 'ui': 'Interfaz',
'theme': 'Tema', 'theme': 'Tema',
'themeSystem': 'Sistema', 'themeSystem': 'Sistema',
@@ -104,5 +108,20 @@ const esES = {
'taskUrl': 'URL de la Tarea', 'taskUrl': 'URL de la Tarea',
'downloadPath': 'Ruta de Descarga', 'downloadPath': 'Ruta de Descarga',
'skipVerifyCert': 'Omitir Verificación de Certificado', 'skipVerifyCert': 'Omitir Verificación de Certificado',
'insertPlaceholder': 'Insertar marcador de posición',
'placeholderYear': 'Año actual',
'placeholderMonth': 'Mes actual (01-12)',
'placeholderDay': 'Día actual (01-31)',
'placeholderDate': 'Fecha completa (AAAA-MM-DD)',
'example': 'ej. @value',
'downloadCategories': 'Categorías de descarga',
'categoryMusic': 'Música',
'categoryVideo': 'Video',
'categoryDocument': 'Documento',
'categoryProgram': 'Programa',
'categoryName': 'Nombre de categoría',
'categoryPath': 'Ruta de categoría',
'builtInCategory': 'La categoría incorporada no se puede eliminar',
'selectCategory': 'Seleccionar categoría',
}, },
}; };
+17 -1
View File
@@ -4,6 +4,7 @@ const faIR = {
'error': 'اررور', 'error': 'اررور',
'tip': 'Tip', 'tip': 'Tip',
'confirm': 'تایید', 'confirm': 'تایید',
'confirmDelete': 'آیا مطمئن هستید که می خواهید حذف کنید؟',
'cancel': 'انصراف', 'cancel': 'انصراف',
'selectAll': 'انتخاب همه', 'selectAll': 'انتخاب همه',
'task': 'کار', 'task': 'کار',
@@ -60,7 +61,22 @@ const faIR = {
'thanks': 'تشکر', 'thanks': 'تشکر',
'thanksDesc': 'thanksDesc':
'از تمامی افرادی که در ساخت و توسعه‌ی جامعه‌ی Gopeed مشارکت کرده‌اند، سپاسگزاری می‌کنیم!', 'از تمامی افرادی که در ساخت و توسعه‌ی جامعه‌ی Gopeed مشارکت کرده‌اند، سپاسگزاری می‌کنیم!',
'browserExtension': 'فایل های دانلود شده را نگه دارد', 'browserExtension': 'افزونه مرورگر',
'skipVerifyCert': 'رد کردن تأیید گواهینامه', 'skipVerifyCert': 'رد کردن تأیید گواهینامه',
'insertPlaceholder': 'درج نگهدارنده',
'placeholderYear': 'سال جاری',
'placeholderMonth': 'ماه جاری (01-12)',
'placeholderDay': 'روز جاری (01-31)',
'placeholderDate': 'تاریخ کامل (YYYY-MM-DD)',
'example': 'مثلا @value',
'downloadCategories': 'دسته‌بندی‌های دانلود',
'categoryMusic': 'موسیقی',
'categoryVideo': 'ویدیو',
'categoryDocument': 'سند',
'categoryProgram': 'برنامه',
'categoryName': 'نام دسته‌بندی',
'categoryPath': 'مسیر دسته‌بندی',
'builtInCategory': 'دسته‌بندی داخلی قابل حذف نیست',
'selectCategory': 'انتخاب دسته‌بندی',
} }
}; };
+29 -7
View File
@@ -4,6 +4,7 @@ const frFR = {
'error': 'Erreur', 'error': 'Erreur',
'tip': 'Conseil', 'tip': 'Conseil',
'confirm': 'Confirmer', 'confirm': 'Confirmer',
'confirmDelete': 'Êtes-vous sûr de vouloir supprimer ?',
'cancel': 'Annuler', 'cancel': 'Annuler',
'on': 'Activé', 'on': 'Activé',
'off': 'Désactivé', 'off': 'Désactivé',
@@ -19,10 +20,12 @@ const frFR = {
'advancedOptions': 'Options avancées', 'advancedOptions': 'Options avancées',
'downloadLink': 'Lien de téléchargement', 'downloadLink': 'Lien de téléchargement',
'downloadLinkValid': 'Veuillez entrer le lien de téléchargement', 'downloadLinkValid': 'Veuillez entrer le lien de téléchargement',
'downloadLinkHit': 'Veuillez entrer le lien de téléchargement, HTTP/HTTPS/MAGNET pris en charge', 'downloadLinkHit':
'Veuillez entrer le lien de téléchargement, HTTP/HTTPS/MAGNET pris en charge',
'downloadLinkHitDesktop': ', ou glissez directement le fichier torrent ici', 'downloadLinkHitDesktop': ', ou glissez directement le fichier torrent ici',
'download': 'Télécharger', 'download': 'Télécharger',
'noFileSelected': 'Veuillez sélectionner au moins un fichier pour continuer.', 'noFileSelected':
'Veuillez sélectionner au moins un fichier pour continuer.',
'noStoragePermission': 'Permission de stockage requise', 'noStoragePermission': 'Permission de stockage requise',
'selectFile': 'Sélectionner un fichier', 'selectFile': 'Sélectionner un fichier',
'rename': 'Renommer', 'rename': 'Renommer',
@@ -32,16 +35,19 @@ const frFR = {
'downloadDir': 'Répertoire de téléchargement', 'downloadDir': 'Répertoire de téléchargement',
'downloadDirValid': 'Veuillez sélectionner le répertoire de téléchargement', 'downloadDirValid': 'Veuillez sélectionner le répertoire de téléchargement',
'connections': 'Connexions', 'connections': 'Connexions',
'useServerCtime': 'Utiliser l\'heure du serveur pour la création de fichier', 'useServerCtime':
'Utiliser l\'heure du serveur pour la création de fichier',
'maxRunning': 'Tâches en cours maximum', 'maxRunning': 'Tâches en cours maximum',
'items': '@count éléments', 'items': '@count éléments',
'subscribeTracker': 'S\'abonner au tracker', 'subscribeTracker': 'S\'abonner au tracker',
'subscribeFail': 'Abonnement échoué, veuillez vérifier le réseau ou réessayer plus tard', 'subscribeFail':
'Abonnement échoué, veuillez vérifier le réseau ou réessayer plus tard',
'update': 'Mettre à jour', 'update': 'Mettre à jour',
'updateDaily': 'Mettre à jour quotidiennement', 'updateDaily': 'Mettre à jour quotidiennement',
'lastUpdate': 'Dernière mise à jour : @time', 'lastUpdate': 'Dernière mise à jour : @time',
'addTracker': 'Ajouter un tracker', 'addTracker': 'Ajouter un tracker',
'addTrackerHit': 'Veuillez entrer l\'URL du serveur de tracker, une par ligne', 'addTrackerHit':
'Veuillez entrer l\'URL du serveur de tracker, une par ligne',
'ui': 'Interface utilisateur', 'ui': 'Interface utilisateur',
'theme': 'Thème', 'theme': 'Thème',
'themeSystem': 'Système', 'themeSystem': 'Système',
@@ -89,9 +95,25 @@ const frFR = {
'username': 'Nom d\'utilisateur', 'username': 'Nom d\'utilisateur',
'password': 'Mot de passe', 'password': 'Mot de passe',
'thanks': 'Merci', 'thanks': 'Merci',
'thanksDesc': 'Merci à tous les contributeurs qui ont aidé à construire et développer la communauté Gopeed !', 'thanksDesc':
'Merci à tous les contributeurs qui ont aidé à construire et développer la communauté Gopeed !',
'browserExtension': 'Extension du navigateur', 'browserExtension': 'Extension du navigateur',
'launchAtStartup': 'Lancer au démarrage', 'launchAtStartup': 'Lancer au démarrage',
'skipVerifyCert': 'Bypass Verifikasi Sertifikat', 'skipVerifyCert': 'Ignorer la vérification du certificat',
'insertPlaceholder': 'Insérer un espace réservé',
'placeholderYear': 'Année en cours',
'placeholderMonth': 'Mois en cours (01-12)',
'placeholderDay': 'Jour en cours (01-31)',
'placeholderDate': 'Date complète (AAAA-MM-JJ)',
'example': 'ex. @value',
'downloadCategories': 'Catégories de téléchargement',
'categoryMusic': 'Musique',
'categoryVideo': 'Vidéo',
'categoryDocument': 'Document',
'categoryProgram': 'Programme',
'categoryName': 'Nom de la catégorie',
'categoryPath': 'Chemin de la catégorie',
'builtInCategory': 'La catégorie intégrée ne peut pas être supprimée',
'selectCategory': 'Sélectionner une catégorie',
}, },
}; };
+24 -4
View File
@@ -4,6 +4,7 @@ const huHU = {
'error': 'Hiba', 'error': 'Hiba',
'tip': 'Tipp', 'tip': 'Tipp',
'confirm': 'Megerősít', 'confirm': 'Megerősít',
'confirmDelete': 'Biztosan törölni szeretné?',
'cancel': 'Mégsem', 'cancel': 'Mégsem',
'on': 'Bekapcsolva', 'on': 'Bekapcsolva',
'off': 'Kikapcsolva', 'off': 'Kikapcsolva',
@@ -21,7 +22,8 @@ const huHU = {
'followSettings': 'Beállítások követése', 'followSettings': 'Beállítások követése',
'downloadLink': 'Letöltési hivatkozás', 'downloadLink': 'Letöltési hivatkozás',
'downloadLinkValid': 'Kérjük, adja meg a letöltési hivatkozást', 'downloadLinkValid': 'Kérjük, adja meg a letöltési hivatkozást',
'downloadLinkHit': 'Kérjük, adja meg a letöltési hivatkozást, soronként egyet@append', 'downloadLinkHit':
'Kérjük, adja meg a letöltési hivatkozást, soronként egyet@append',
'downloadLinkHitDesktop': ', vagy húzza ide a torrent fájlt közvetlenül', 'downloadLinkHitDesktop': ', vagy húzza ide a torrent fájlt közvetlenül',
'download': 'Letöltés', 'download': 'Letöltés',
'noFileSelected': 'Kérjük, válasszon ki legalább egy fájlt a folytatáshoz.', 'noFileSelected': 'Kérjük, válasszon ki legalább egy fájlt a folytatáshoz.',
@@ -36,7 +38,8 @@ const huHU = {
'connections': 'Kapcsolatok', 'connections': 'Kapcsolatok',
'useServerCtime': 'Használja a szerveridőt a fájl létrehozásához', 'useServerCtime': 'Használja a szerveridőt a fájl létrehozásához',
'maxRunning': 'Maximális futó feladatok', 'maxRunning': 'Maximális futó feladatok',
'defaultDirectDownload': 'Alapértelmezettként ellenőrizze a közvetlen letöltést', 'defaultDirectDownload':
'Alapértelmezettként ellenőrizze a közvetlen letöltést',
'items': '@count elem', 'items': '@count elem',
'subscribeTracker': 'Nyomkövető előfizetése', 'subscribeTracker': 'Nyomkövető előfizetése',
'subscribeFail': 'subscribeFail':
@@ -45,7 +48,8 @@ const huHU = {
'updateDaily': 'Napi frissítés', 'updateDaily': 'Napi frissítés',
'lastUpdate': 'Utolsó frissítés: @time', 'lastUpdate': 'Utolsó frissítés: @time',
'addTracker': 'Nyomkövető hozzáadása', 'addTracker': 'Nyomkövető hozzáadása',
'addTrackerHit': 'Kérjük, adja meg a nyomkövető szerver URL-jét, soronként egyet', 'addTrackerHit':
'Kérjük, adja meg a nyomkövető szerver URL-jét, soronként egyet',
'ui': 'Felhasználói felület', 'ui': 'Felhasználói felület',
'theme': 'Téma', 'theme': 'Téma',
'themeSystem': 'Rendszer', 'themeSystem': 'Rendszer',
@@ -60,7 +64,8 @@ const huHU = {
'apiToken': 'API token', 'apiToken': 'API token',
'notSet': 'NS', 'notSet': 'NS',
'set': 'BEÁLLÍT', 'set': 'BEÁLLÍT',
'portInUse': 'A [@port] port használatban van, kérjük, változtassa meg a portot', 'portInUse':
'A [@port] port használatban van, kérjük, változtassa meg a portot',
'effectAfterRestart': 'Kihatás indítás után', 'effectAfterRestart': 'Kihatás indítás után',
'developer': 'Fejlesztő', 'developer': 'Fejlesztő',
'logDirectory': 'Napló könyvtár', 'logDirectory': 'Napló könyvtár',
@@ -118,5 +123,20 @@ const huHU = {
'fileSelectedSize': 'Méret: ', 'fileSelectedSize': 'Méret: ',
'httpHeaderName': 'Fejléc neve', 'httpHeaderName': 'Fejléc neve',
'httpHeaderValue': 'Fejléc értéke', 'httpHeaderValue': 'Fejléc értéke',
'insertPlaceholder': 'Helyőrző beszúrása',
'placeholderYear': 'Aktuális év',
'placeholderMonth': 'Aktuális hónap (01-12)',
'placeholderDay': 'Aktuális nap (01-31)',
'placeholderDate': 'Teljes dátum (ÉÉÉÉ-HH-NN)',
'example': 'pl. @value',
'downloadCategories': 'Letöltési kategóriák',
'categoryMusic': 'Zene',
'categoryVideo': 'Videó',
'categoryDocument': 'Dokumentum',
'categoryProgram': 'Program',
'categoryName': 'Kategória neve',
'categoryPath': 'Kategória útvonala',
'builtInCategory': 'A beépített kategória nem törölhető',
'selectCategory': 'Kategória kiválasztása',
}, },
}; };
+18 -1
View File
@@ -4,6 +4,7 @@ const idID = {
'error': 'Kesalahan', 'error': 'Kesalahan',
'tip': 'Tips', 'tip': 'Tips',
'confirm': 'Konfirmasi', 'confirm': 'Konfirmasi',
'confirmDelete': 'Apakah Anda yakin ingin menghapus?',
'cancel': 'Batal', 'cancel': 'Batal',
'on': 'Aktif', 'on': 'Aktif',
'off': 'Nonaktif', 'off': 'Nonaktif',
@@ -22,7 +23,8 @@ const idID = {
'downloadLinkValid': 'Silakan masukkan tautan unduhan', 'downloadLinkValid': 'Silakan masukkan tautan unduhan',
'downloadLinkHit': 'downloadLinkHit':
'Tolong masukkan tautan unduhan, HTTP/HTTPS/MAGNET didukung@append', 'Tolong masukkan tautan unduhan, HTTP/HTTPS/MAGNET didukung@append',
'downloadLinkHitDesktop': ', atau seret file torrent ke sini secara langsung', 'downloadLinkHitDesktop':
', atau seret file torrent ke sini secara langsung',
'download': 'Download', 'download': 'Download',
'noFileSelected': 'Silakan pilih setidaknya satu file untuk melanjutkan.', 'noFileSelected': 'Silakan pilih setidaknya satu file untuk melanjutkan.',
'noStoragePermission': 'Izin penyimpanan diperlukan', 'noStoragePermission': 'Izin penyimpanan diperlukan',
@@ -108,5 +110,20 @@ const idID = {
'taskUrl': 'URL Tugas', 'taskUrl': 'URL Tugas',
'downloadPath': 'Direktori Unduhan', 'downloadPath': 'Direktori Unduhan',
'skipVerifyCert': 'Lupakan verifikasi sertifikat', 'skipVerifyCert': 'Lupakan verifikasi sertifikat',
'insertPlaceholder': 'Sisipkan placeholder',
'placeholderYear': 'Tahun saat ini',
'placeholderMonth': 'Bulan saat ini (01-12)',
'placeholderDay': 'Hari saat ini (01-31)',
'placeholderDate': 'Tanggal lengkap (YYYY-MM-DD)',
'example': 'cth. @value',
'downloadCategories': 'Kategori Unduhan',
'categoryMusic': 'Musik',
'categoryVideo': 'Video',
'categoryDocument': 'Dokumen',
'categoryProgram': 'Program',
'categoryName': 'Nama Kategori',
'categoryPath': 'Path Kategori',
'builtInCategory': 'Kategori bawaan tidak dapat dihapus',
'selectCategory': 'Pilih Kategori',
}, },
}; };
+16
View File
@@ -4,6 +4,7 @@ const itIT = {
'error': 'Errore', 'error': 'Errore',
'tip': 'Suggerimento', 'tip': 'Suggerimento',
'confirm': 'Conferma', 'confirm': 'Conferma',
'confirmDelete': 'Sei sicuro di voler eliminare?',
'cancel': 'Annullla', 'cancel': 'Annullla',
'on': 'Acceso', 'on': 'Acceso',
'off': 'Spento', 'off': 'Spento',
@@ -97,5 +98,20 @@ const itIT = {
'browserExtension': 'Estensione del browser', 'browserExtension': 'Estensione del browser',
'launchAtStartup': "Lancia all'avvio", 'launchAtStartup': "Lancia all'avvio",
'skipVerifyCert': 'Salta la verifica del certificato', 'skipVerifyCert': 'Salta la verifica del certificato',
'insertPlaceholder': 'Inserisci segnaposto',
'placeholderYear': 'Anno corrente',
'placeholderMonth': 'Mese corrente (01-12)',
'placeholderDay': 'Giorno corrente (01-31)',
'placeholderDate': 'Data completa (AAAA-MM-GG)',
'example': 'es. @value',
'downloadCategories': 'Categorie di download',
'categoryMusic': 'Musica',
'categoryVideo': 'Video',
'categoryDocument': 'Documento',
'categoryProgram': 'Programma',
'categoryName': 'Nome categoria',
'categoryPath': 'Percorso categoria',
'builtInCategory': 'La categoria predefinita non può essere eliminata',
'selectCategory': 'Seleziona categoria',
}, },
}; };
+16
View File
@@ -4,6 +4,7 @@ const jaJP = {
'error': 'エラー', 'error': 'エラー',
'tip': 'ヒント', 'tip': 'ヒント',
'confirm': '確認', 'confirm': '確認',
'confirmDelete': '削除してもよろしいですか?',
'cancel': 'キャンセル', 'cancel': 'キャンセル',
'selectAll': 'すべてを選択', 'selectAll': 'すべてを選択',
'task': 'タスク', 'task': 'タスク',
@@ -63,5 +64,20 @@ const jaJP = {
'thanksDesc': 'Gopeedコミュニティの建設に協力してくださったすべての貢献者の方々に感謝します!', 'thanksDesc': 'Gopeedコミュニティの建設に協力してくださったすべての貢献者の方々に感謝します!',
'browserExtension': 'ブラウザ拡張機能', 'browserExtension': 'ブラウザ拡張機能',
'skipVerifyCert': '証明書の検証をスキップ', 'skipVerifyCert': '証明書の検証をスキップ',
'insertPlaceholder': 'プレースホルダーを挿入',
'placeholderYear': '現在の年',
'placeholderMonth': '現在の月 (01-12)',
'placeholderDay': '現在の日 (01-31)',
'placeholderDate': '完全な日付 (年-月-日)',
'example': '例: @value',
'downloadCategories': 'ダウンロードカテゴリ',
'categoryMusic': '音楽',
'categoryVideo': '動画',
'categoryDocument': 'ドキュメント',
'categoryProgram': 'プログラム',
'categoryName': 'カテゴリ名',
'categoryPath': 'カテゴリパス',
'builtInCategory': '内蔵カテゴリは削除できません',
'selectCategory': 'カテゴリを選択',
} }
}; };
+16
View File
@@ -4,6 +4,7 @@ const plPL = {
'error': 'Błąd', 'error': 'Błąd',
'tip': 'Tip', 'tip': 'Tip',
'confirm': 'Potwierdź', 'confirm': 'Potwierdź',
'confirmDelete': 'Czy na pewno chcesz usunąć?',
'cancel': 'Anuluj', 'cancel': 'Anuluj',
'on': 'Włącz', 'on': 'Włącz',
'off': 'Wyłącz', 'off': 'Wyłącz',
@@ -95,5 +96,20 @@ const plPL = {
'browserExtension': 'Wtyczka dla przeglądarki', 'browserExtension': 'Wtyczka dla przeglądarki',
'launchAtStartup': 'Uruchom na starcie', 'launchAtStartup': 'Uruchom na starcie',
'skipVerifyCert': 'Pomiń weryfikację certyfikatu', 'skipVerifyCert': 'Pomiń weryfikację certyfikatu',
'insertPlaceholder': 'Wstaw placeholder',
'placeholderYear': 'Bieżący rok',
'placeholderMonth': 'Bieżący miesiąc (01-12)',
'placeholderDay': 'Bieżący dzień (01-31)',
'placeholderDate': 'Pełna data (RRRR-MM-DD)',
'example': 'np. @value',
'downloadCategories': 'Kategorie pobierania',
'categoryMusic': 'Muzyka',
'categoryVideo': 'Wideo',
'categoryDocument': 'Dokument',
'categoryProgram': 'Program',
'categoryName': 'Nazwa kategorii',
'categoryPath': 'Ścieżka kategorii',
'builtInCategory': 'Wbudowanej kategorii nie można usunąć',
'selectCategory': 'Wybierz kategorię',
}, },
}; };
+18 -1
View File
@@ -4,6 +4,7 @@ const ptBR = {
'error': 'Erro', 'error': 'Erro',
'tip': 'Dica', 'tip': 'Dica',
'confirm': 'Confirmar', 'confirm': 'Confirmar',
'confirmDelete': 'Tem certeza de que deseja excluir?',
'cancel': 'Cancelar', 'cancel': 'Cancelar',
'on': 'Ligado', 'on': 'Ligado',
'off': 'Desligado', 'off': 'Desligado',
@@ -21,7 +22,8 @@ const ptBR = {
'followSettings': 'Follow Settings', 'followSettings': 'Follow Settings',
'downloadLink': 'Link de Download', 'downloadLink': 'Link de Download',
'downloadLinkValid': 'Por favor, insira o link de download', 'downloadLinkValid': 'Por favor, insira o link de download',
'downloadLinkHit': 'Por favor, insira o link de download, um por linha@append', 'downloadLinkHit':
'Por favor, insira o link de download, um por linha@append',
'downloadLinkHitDesktop': ', ou arraste o arquivo torrent aqui', 'downloadLinkHitDesktop': ', ou arraste o arquivo torrent aqui',
'download': 'Download', 'download': 'Download',
'noFileSelected': 'Por favor, selecione ao menos um arquivo para continuar', 'noFileSelected': 'Por favor, selecione ao menos um arquivo para continuar',
@@ -118,5 +120,20 @@ const ptBR = {
'fileSelectedSize': 'Tamanho: ', 'fileSelectedSize': 'Tamanho: ',
'httpHeaderName': 'Nome do Header', 'httpHeaderName': 'Nome do Header',
'httpHeaderValue': 'Valor do Header', 'httpHeaderValue': 'Valor do Header',
'insertPlaceholder': 'Inserir marcador',
'placeholderYear': 'Ano atual',
'placeholderMonth': 'Mês atual (01-12)',
'placeholderDay': 'Dia atual (01-31)',
'placeholderDate': 'Data completa (AAAA-MM-DD)',
'example': 'ex. @value',
'downloadCategories': 'Categorias de Download',
'categoryMusic': 'Música',
'categoryVideo': 'Vídeo',
'categoryDocument': 'Documento',
'categoryProgram': 'Programa',
'categoryName': 'Nome da Categoria',
'categoryPath': 'Caminho da Categoria',
'builtInCategory': 'Categoria integrada não pode ser excluída',
'selectCategory': 'Selecionar Categoria',
}, },
}; };
+16
View File
@@ -4,6 +4,7 @@ const ruRU = {
'error': 'Ошибка', 'error': 'Ошибка',
'tip': 'Подсказка', 'tip': 'Подсказка',
'confirm': 'Подтвердить', 'confirm': 'Подтвердить',
'confirmDelete': 'Вы уверены, что хотите удалить?',
'cancel': 'Отмена', 'cancel': 'Отмена',
'on': 'Вкл.', 'on': 'Вкл.',
'off': 'Откл.', 'off': 'Откл.',
@@ -119,5 +120,20 @@ const ruRU = {
'fileSelectedSize': 'Размер: ', 'fileSelectedSize': 'Размер: ',
'httpHeaderName': 'Название заголовка HTTP', 'httpHeaderName': 'Название заголовка HTTP',
'httpHeaderValue': 'Значение заголовка HTTP', 'httpHeaderValue': 'Значение заголовка HTTP',
'insertPlaceholder': 'Вставить заполнитель',
'placeholderYear': 'Текущий год',
'placeholderMonth': 'Текущий месяц (01-12)',
'placeholderDay': 'Текущий день (01-31)',
'placeholderDate': 'Полная дата (ГГГГ-ММ-ДД)',
'example': 'напр. @value',
'downloadCategories': 'Категории загрузки',
'categoryMusic': 'Музыка',
'categoryVideo': 'Видео',
'categoryDocument': 'Документ',
'categoryProgram': 'Программа',
'categoryName': 'Название категории',
'categoryPath': 'Путь категории',
'builtInCategory': 'Встроенную категорию нельзя удалить',
'selectCategory': 'Выбрать категорию',
} }
}; };
+18 -1
View File
@@ -4,6 +4,7 @@ const taTA = {
'error': 'பிழை', 'error': 'பிழை',
'tip': 'குறிப்பு', 'tip': 'குறிப்பு',
'confirm': 'சரி', 'confirm': 'சரி',
'confirmDelete': 'நிச்சயமாக நீக்க விரும்புகிறீர்களா?',
'cancel': 'ரத்துசெய்', 'cancel': 'ரத்துசெய்',
'on': 'On', 'on': 'On',
'off': 'Off', 'off': 'Off',
@@ -21,7 +22,8 @@ const taTA = {
'downloadLinkValid': 'பதிவிறக்க இணைப்பை உள்ளிடவும்', 'downloadLinkValid': 'பதிவிறக்க இணைப்பை உள்ளிடவும்',
'downloadLinkHit': 'downloadLinkHit':
'பதிவிறக்க இணைப்பை உள்ளிடவும், HTTP/HTTPS/MAGNET ஆகியவற்றை ஏற்கும்@append', 'பதிவிறக்க இணைப்பை உள்ளிடவும், HTTP/HTTPS/MAGNET ஆகியவற்றை ஏற்கும்@append',
'downloadLinkHitDesktop': ', அல்லது torrent கோப்பை நேரடியாக இங்கே இழுக்கவும்', 'downloadLinkHitDesktop':
', அல்லது torrent கோப்பை நேரடியாக இங்கே இழுக்கவும்',
'download': 'பதிவிறக்கு', 'download': 'பதிவிறக்கு',
'noFileSelected': 'குறைந்தபட்சம் ஒரு கோப்பைத் தேர்ந்தெடுக்கவும்.', 'noFileSelected': 'குறைந்தபட்சம் ஒரு கோப்பைத் தேர்ந்தெடுக்கவும்.',
'noStoragePermission': 'சேமிப்பக அனுமதி தேவை', 'noStoragePermission': 'சேமிப்பக அனுமதி தேவை',
@@ -96,5 +98,20 @@ const taTA = {
'browserExtension': 'உலாவி நீட்டிப்பு', 'browserExtension': 'உலாவி நீட்டிப்பு',
'launchAtStartup': 'தொடக்கத்தின் போது இயங்கு', 'launchAtStartup': 'தொடக்கத்தின் போது இயங்கு',
'skipVerifyCert': 'திருத்தி சான்று சரிபார்க்கவும்', 'skipVerifyCert': 'திருத்தி சான்று சரிபார்க்கவும்',
'insertPlaceholder': 'இடங்காட்டி செருக',
'placeholderYear': 'தற்போதைய ஆண்டு',
'placeholderMonth': 'தற்போதைய மாதம் (01-12)',
'placeholderDay': 'தற்போதைய நாள் (01-31)',
'placeholderDate': 'முழு தேதி (YYYY-MM-DD)',
'example': 'எ.கா. @value',
'downloadCategories': 'பதிவிறக்க வகைகள்',
'categoryMusic': 'இசை',
'categoryVideo': 'காணொளி',
'categoryDocument': 'ஆவணம்',
'categoryProgram': 'நிரல்',
'categoryName': 'வகை பெயர்',
'categoryPath': 'வகை பாதை',
'builtInCategory': 'உள்ளமைக்கப்பட்ட வகையை நீக்க முடியாது',
'selectCategory': 'வகையைத் தேர்ந்தெடுக்கவும்',
}, },
}; };
+24 -5
View File
@@ -4,6 +4,7 @@ const trTR = {
'error': 'Hata', 'error': 'Hata',
'tip': 'İpucu', 'tip': 'İpucu',
'confirm': 'Onayla', 'confirm': 'Onayla',
'confirmDelete': 'Silmek istediğinizden emin misiniz?',
'cancel': 'İptal', 'cancel': 'İptal',
'on': 'Açık', 'on': 'Açık',
'off': 'Kapalı', 'off': 'Kapalı',
@@ -22,7 +23,8 @@ const trTR = {
'downloadLink': 'İndirme Bağlantısı', 'downloadLink': 'İndirme Bağlantısı',
'downloadLinkValid': 'Lütfen indirme bağlantısını girin', 'downloadLinkValid': 'Lütfen indirme bağlantısını girin',
'downloadLinkHit': 'Lütfen her satıra bir indirme bağlantısı girin@append', 'downloadLinkHit': 'Lütfen her satıra bir indirme bağlantısı girin@append',
'downloadLinkHitDesktop': ', veya doğrudan torrent dosyasını buraya sürükleyin', 'downloadLinkHitDesktop':
', veya doğrudan torrent dosyasını buraya sürükleyin',
'download': 'İndir', 'download': 'İndir',
'noFileSelected': 'Devam etmek için en az bir dosya seçin.', 'noFileSelected': 'Devam etmek için en az bir dosya seçin.',
'noStoragePermission': 'Depolama izni gerekli', 'noStoragePermission': 'Depolama izni gerekli',
@@ -39,12 +41,14 @@ const trTR = {
'defaultDirectDownload': 'Varsayılan olarak doğrudan indirmeyi seç', 'defaultDirectDownload': 'Varsayılan olarak doğrudan indirmeyi seç',
'items': '@count öğe', 'items': '@count öğe',
'subscribeTracker': 'Tracker Aboneliği', 'subscribeTracker': 'Tracker Aboneliği',
'subscribeFail': 'Abonelik başarısız oldu, lütfen ağı kontrol edin veya daha sonra tekrar deneyin', 'subscribeFail':
'Abonelik başarısız oldu, lütfen ağı kontrol edin veya daha sonra tekrar deneyin',
'update': 'Güncelle', 'update': 'Güncelle',
'updateDaily': 'Günlük güncelle', 'updateDaily': 'Günlük güncelle',
'lastUpdate': 'Son güncelleme: @time', 'lastUpdate': 'Son güncelleme: @time',
'addTracker': 'Tracker Ekle', 'addTracker': 'Tracker Ekle',
'addTrackerHit': 'Lütfen tracker sunucu URL’sini girin, her satıra bir tane', 'addTrackerHit':
'Lütfen tracker sunucu URL’sini girin, her satıra bir tane',
'ui': 'Arayüz', 'ui': 'Arayüz',
'theme': 'Tema', 'theme': 'Tema',
'themeSystem': 'Sistem', 'themeSystem': 'Sistem',
@@ -96,8 +100,8 @@ const trTR = {
'username': 'Kullanıcı Adı', 'username': 'Kullanıcı Adı',
'password': 'Şifre', 'password': 'Şifre',
'thanks': 'Teşekkürler', 'thanks': 'Teşekkürler',
'thanksDesc': 'thanksDesc':
'Gopeed topluluğunun inşasında ve geliştirilmesinde katkıda bulunan herkese teşekkürler!', 'Gopeed topluluğunun inşasında ve geliştirilmesinde katkıda bulunan herkese teşekkürler!',
'browserExtension': 'Tarayıcı Eklentisi', 'browserExtension': 'Tarayıcı Eklentisi',
'launchAtStartup': 'Başlangıçta Çalıştır', 'launchAtStartup': 'Başlangıçta Çalıştır',
'seedConfig': 'Seed Ayarları', 'seedConfig': 'Seed Ayarları',
@@ -117,5 +121,20 @@ const trTR = {
'fileSelectedSize': 'Boyut: ', 'fileSelectedSize': 'Boyut: ',
'httpHeaderName': 'Başlık Adı', 'httpHeaderName': 'Başlık Adı',
'httpHeaderValue': 'Başlık Değeri', 'httpHeaderValue': 'Başlık Değeri',
'insertPlaceholder': 'Yer tutucu ekle',
'placeholderYear': 'Geçerli yıl',
'placeholderMonth': 'Geçerli ay (01-12)',
'placeholderDay': 'Geçerli gün (01-31)',
'placeholderDate': 'Tam tarih (YYYY-AA-GG)',
'example': 'örn. @value',
'downloadCategories': 'İndirme Kategorileri',
'categoryMusic': 'Müzik',
'categoryVideo': 'Video',
'categoryDocument': 'Belge',
'categoryProgram': 'Program',
'categoryName': 'Kategori Adı',
'categoryPath': 'Kategori Yolu',
'builtInCategory': 'Yerleşik kategori silinemez',
'selectCategory': 'Kategori Seç',
}, },
}; };
+16
View File
@@ -4,6 +4,7 @@ const ukUA = {
'error': 'Помилка', 'error': 'Помилка',
'tip': 'Підказка', 'tip': 'Підказка',
'confirm': 'Підтвердити', 'confirm': 'Підтвердити',
'confirmDelete': 'Ви впевнені, що хочете видалити?',
'cancel': 'Скасувати', 'cancel': 'Скасувати',
'on': 'Увімк.', 'on': 'Увімк.',
'off': 'Вимк.', 'off': 'Вимк.',
@@ -119,5 +120,20 @@ const ukUA = {
'fileSelectedSize': 'Розмір: ', 'fileSelectedSize': 'Розмір: ',
'httpHeaderName': 'Назва заголовка HTTP', 'httpHeaderName': 'Назва заголовка HTTP',
'httpHeaderValue': 'Значення заголовка HTTP', 'httpHeaderValue': 'Значення заголовка HTTP',
'insertPlaceholder': 'Вставити заповнювач',
'placeholderYear': 'Поточний рік',
'placeholderMonth': 'Поточний місяць (01-12)',
'placeholderDay': 'Поточний день (01-31)',
'placeholderDate': 'Повна дата (РРРР-ММ-ДД)',
'example': 'напр. @value',
'downloadCategories': 'Категорії завантажень',
'categoryMusic': 'Музика',
'categoryVideo': 'Відео',
'categoryDocument': 'Документ',
'categoryProgram': 'Програма',
'categoryName': 'Назва категорії',
'categoryPath': 'Шлях категорії',
'builtInCategory': 'Вбудовану категорію неможливо видалити',
'selectCategory': 'Вибрати категорію',
}, },
}; };
+16
View File
@@ -4,6 +4,7 @@ const viVN = {
'error': 'Lỗi', 'error': 'Lỗi',
'tip': 'Mẹo', 'tip': 'Mẹo',
'confirm': 'Xác nhận', 'confirm': 'Xác nhận',
'confirmDelete': 'Bạn có chắc chắn muốn xóa?',
'cancel': 'Hủy', 'cancel': 'Hủy',
'on': 'Bật', 'on': 'Bật',
'off': 'Tắt', 'off': 'Tắt',
@@ -90,5 +91,20 @@ const viVN = {
'Cảm ơn tất cả những người đóng góp đã giúp xây dựng và phát triển cộng đồng Gopeed!', 'Cảm ơn tất cả những người đóng góp đã giúp xây dựng và phát triển cộng đồng Gopeed!',
'browserExtension': 'Tiện ích mở rộng trình duyệt', 'browserExtension': 'Tiện ích mở rộng trình duyệt',
'skipVerifyCert': 'Bỏ qua xác thực chứng chỉ', 'skipVerifyCert': 'Bỏ qua xác thực chứng chỉ',
'insertPlaceholder': 'Chèn placeholder',
'placeholderYear': 'Năm hiện tại',
'placeholderMonth': 'Tháng hiện tại (01-12)',
'placeholderDay': 'Ngày hiện tại (01-31)',
'placeholderDate': 'Ngày đầy đủ (YYYY-MM-DD)',
'example': 'vd. @value',
'downloadCategories': 'Danh mục tải xuống',
'categoryMusic': 'Nhạc',
'categoryVideo': 'Video',
'categoryDocument': 'Tài liệu',
'categoryProgram': 'Chương trình',
'categoryName': 'Tên danh mục',
'categoryPath': 'Đường dẫn danh mục',
'builtInCategory': 'Không thể xóa danh mục tích hợp',
'selectCategory': 'Chọn danh mục',
}, },
}; };
+33
View File
@@ -4,6 +4,7 @@ const zhCN = {
'error': '错误', 'error': '错误',
'tip': '提示', 'tip': '提示',
'confirm': '确认', 'confirm': '确认',
'confirmDelete': '确定要删除吗?',
'cancel': '取消', 'cancel': '取消',
'on': '开启', 'on': '开启',
'off': '关闭', 'off': '关闭',
@@ -64,6 +65,15 @@ const zhCN = {
'effectAfterRestart': '此配置项将在重启应用后生效', 'effectAfterRestart': '此配置项将在重启应用后生效',
'developer': '开发者', 'developer': '开发者',
'logDirectory': '日志目录', 'logDirectory': '日志目录',
'webhook': 'Webhook 推送',
'webhookEnable': '启用 Webhook',
'webhookDesc': '在任务完成或失败时发送 HTTP POST 通知',
'webhookUrlHint': '请输入 Webhook URL',
'webhookTest': '测试',
'webhookTestSuccess': 'Webhook 测试成功',
'webhookTestFail': 'Webhook 测试失败',
'urlInvalid': '请输入有效的 HTTP 或 HTTPS 链接',
'required': '此项为必填项',
'show': '显示', 'show': '显示',
'continue': '继续', 'continue': '继续',
'pause': '暂停', 'pause': '暂停',
@@ -72,6 +82,8 @@ const zhCN = {
'deleteTask': '删除 @count 个任务', 'deleteTask': '删除 @count 个任务',
'deleteTaskTip': '保留已下载的文件', 'deleteTaskTip': '保留已下载的文件',
'delete': '删除', 'delete': '删除',
'add': '添加',
'edit': '编辑',
'newVersionTitle': '发现新版本 @version', 'newVersionTitle': '发现新版本 @version',
'newVersionUpdate': '立即更新', 'newVersionUpdate': '立即更新',
'newVersionLater': '稍后再说', 'newVersionLater': '稍后再说',
@@ -123,5 +135,26 @@ const zhCN = {
'login_success': '登录成功', 'login_success': '登录成功',
'login_failed': '登录失败,请检查用户名和密码', 'login_failed': '登录失败,请检查用户名和密码',
'login_failed_network': '登录失败,请检查网络连接', 'login_failed_network': '登录失败,请检查网络连接',
'insertPlaceholder': '插入占位符',
'placeholderYear': '当前年份',
'placeholderMonth': '当前月份 (01-12)',
'placeholderDay': '当前日期 (01-31)',
'placeholderDate': '完整日期 (年-月-日)',
'example': '例如 @value',
'downloadCategories': '下载目录分类',
'categoryMusic': '音乐',
'categoryVideo': '视频',
'categoryDocument': '文档',
'categoryProgram': '程序',
'categoryName': '分类名称',
'categoryPath': '分类路径',
'builtInCategory': '内置分类不能删除',
'selectCategory': '选择分类',
'githubMirror': 'GitHub 镜像',
'githubMirrorEnable': '启用 GitHub 镜像',
'githubMirrorDesc': '使用镜像站点加速 GitHub 内容下载',
'githubMirrorType': '镜像类型',
'githubMirrorUrl': '镜像地址',
'githubMirrorUrlHint': '请输入镜像地址',
} }
}; };
+16
View File
@@ -4,6 +4,7 @@ const zhTW = {
'error': '錯誤', 'error': '錯誤',
'tip': '提示', 'tip': '提示',
'confirm': '確認', 'confirm': '確認',
'confirmDelete': '確定要刪除嗎?',
'cancel': '取消', 'cancel': '取消',
'on': '開啟', 'on': '開啟',
'off': '關閉', 'off': '關閉',
@@ -116,5 +117,20 @@ const zhTW = {
'fileSelectedSize': '大小:', 'fileSelectedSize': '大小:',
'httpHeaderName': '標頭名稱', 'httpHeaderName': '標頭名稱',
'httpHeaderValue': '標頭值', 'httpHeaderValue': '標頭值',
'insertPlaceholder': '插入佔位符',
'placeholderYear': '當前年份',
'placeholderMonth': '當前月份 (01-12)',
'placeholderDay': '當前日期 (01-31)',
'placeholderDate': '完整日期 (年-月-日)',
'example': '例如 @value',
'downloadCategories': '下載目錄分類',
'categoryMusic': '音樂',
'categoryVideo': '影片',
'categoryDocument': '文件',
'categoryProgram': '程式',
'categoryName': '分類名稱',
'categoryPath': '分類路徑',
'builtInCategory': '內建分類無法刪除',
'selectCategory': '選擇分類',
} }
}; };
@@ -51,6 +51,7 @@ Future<bool> doCheckBrowserInstalled(Browser browser) async {
return await _checkWindowsRegistry(r'SOFTWARE\Mozilla\Firefox') || return await _checkWindowsRegistry(r'SOFTWARE\Mozilla\Firefox') ||
await _checkWindowsRegistry( await _checkWindowsRegistry(
r'SOFTWARE\WOW6432Node\Mozilla\Firefox') || r'SOFTWARE\WOW6432Node\Mozilla\Firefox') ||
await _checkWindowsRegistry(r'SOFTWARE\BrowserWorks\Waterfox') ||
await _checkWindowsExecutable(browser); await _checkWindowsExecutable(browser);
} }
} else { } else {
@@ -144,6 +145,10 @@ List<String> _getWindowsExecutablePaths(Browser browser) {
path.join(programFiles, 'Mozilla Firefox', 'firefox.exe'), path.join(programFiles, 'Mozilla Firefox', 'firefox.exe'),
if (programFilesX86 != null) if (programFilesX86 != null)
path.join(programFilesX86, 'Mozilla Firefox', 'firefox.exe'), path.join(programFilesX86, 'Mozilla Firefox', 'firefox.exe'),
if (programFiles != null)
path.join(programFiles, 'Waterfox', 'waterfox.exe'),
if (programFilesX86 != null)
path.join(programFilesX86, 'Waterfox', 'waterfox.exe'),
]; ];
} }
} }
@@ -178,7 +183,10 @@ List<String> _getUnixExecutablePaths(Browser browser) {
return [ return [
'/Applications/Firefox.app', '/Applications/Firefox.app',
'~/Applications/Firefox.app', '~/Applications/Firefox.app',
'/Users/${Platform.environment['USER']}/Applications/Firefox.app' '/Users/${Platform.environment['USER']}/Applications/Firefox.app',
'/Applications/Waterfox.app',
'~/Applications/Waterfox.app',
'/Users/${Platform.environment['USER']}/Applications/Waterfox.app'
]; ];
} }
} else { } else {
@@ -203,7 +211,11 @@ List<String> _getUnixExecutablePaths(Browser browser) {
'/usr/bin/firefox', '/usr/bin/firefox',
'/snap/bin/firefox', '/snap/bin/firefox',
'/usr/lib/firefox/firefox', '/usr/lib/firefox/firefox',
'/opt/firefox/firefox' '/opt/firefox/firefox',
'/usr/bin/waterfox',
'/snap/bin/waterfox',
'/usr/lib/waterfox/waterfox',
'/opt/waterfox/waterfox'
]; ];
} }
} }
+56 -35
View File
@@ -1,39 +1,41 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:get/get.dart';
// List of mirrors for GitHub import '../api/model/downloader_config.dart';
const _sourceMirror = [ import '../app/modules/app/controllers/app_controller.dart';
// github.tbedu.top: https://github.tbedu.top/https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt
["https://github.tbedu.top/", r"(.*)"],
// fastgit.cc: https://fastgit.cc/https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt
["https://fastgit.cc/", r"(.*)"],
// gitproxy.click: https://gitproxy.click/https://raw.githubusercontent.com/ngosang/trackerslist/master/trackers_all.txt
["https://gitproxy.click/", r"(.*)"],
// jsdelivr: https://fastly.jsdelivr.net/gh/ngosang/trackerslist/trackers_all.txt
[
"https://fastly.jsdelivr.net/gh",
r".*raw.githubusercontent.com(/.*)(/.*)/(?:master|main)(/.*)"
],
];
// List of mirrors for GitHub release
const _assertMirror = [
// github.tbedu.top: https://https://github.tbedu.top/https://github.com/GopeedLab/gopeed/releases/download/v1.6.10/Gopeed-v1.6.10-windows-amd64.zip
["https://github.tbedu.top/", r"(.*)"],
// fastgit.cc: https://fastgit.cc/https://github.com/GopeedLab/gopeed/releases/download/v1.6.10/Gopeed-v1.6.10-windows-amd64.zip
["https://fastgit.cc/", r"(.*)"],
// gitproxy.click: https://gitproxy.click/https://github.com/GopeedLab/gopeed/releases/download/v1.6.10/Gopeed-v1.6.10-windows-amd64.zip
["https://gitproxy.click/", r"(.*)"],
];
enum MirrorType { enum MirrorType {
githubSource, githubSource,
githubRelease, githubRelease,
} }
/// Get the configured mirrors
List<GithubMirror> _getConfiguredMirrors() {
try {
final appController = Get.find<AppController>();
final config = appController.downloaderConfig.value.extra.githubMirror;
if (!config.enabled) {
return [];
}
// Return configured mirrors (filtering not deleted ones)
return config.mirrors.where((m) => !m.isDeleted).toList();
} catch (e) {
// Fallback to empty list if controller not found
return [];
}
}
/// Auto detect the best mirror for the given [rawUrl] and [type] /// Auto detect the best mirror for the given [rawUrl] and [type]
Future<String> githubAutoMirror(String rawUrl, MirrorType type) async { Future<String> githubAutoMirror(String rawUrl, MirrorType type) async {
final mirrorUrls = githubMirrorUrls(rawUrl, type); final mirrorUrls = githubMirrorUrls(rawUrl, type);
// If no mirrors, return original URL
if (mirrorUrls.isEmpty) {
return rawUrl;
}
// Ping all mirrors and get the fastest one // Ping all mirrors and get the fastest one
final pingResult = await Future.wait(mirrorUrls.map((e) async { final pingResult = await Future.wait(mirrorUrls.map((e) async {
final client = Dio() final client = Dio()
@@ -64,20 +66,39 @@ Future<String> githubAutoMirror(String rawUrl, MirrorType type) async {
} }
List<String> githubMirrorUrls(String rawUrl, MirrorType type) { List<String> githubMirrorUrls(String rawUrl, MirrorType type) {
final mirrors = switch (type) { final mirrors = _getConfiguredMirrors();
MirrorType.githubSource => _sourceMirror,
MirrorType.githubRelease => _assertMirror,
};
final ret = <String>[]; final ret = <String>[];
for (final cdn in mirrors) { for (final mirror in mirrors) {
final reg = RegExp(cdn[1]); String? mirrorUrl;
final match = reg.firstMatch(rawUrl.toString());
var matchStr = ""; if (mirror.type == GithubMirrorType.jsdelivr) {
for (var i = 1; i <= match!.groupCount; i++) { // jsdelivr only supports source files
matchStr += match.group(i)!; if (type != MirrorType.githubSource) {
continue;
}
// Transform: https://raw.githubusercontent.com/user/repo/master/path
// To: https://fastly.jsdelivr.net/gh/user/repo/path
final jsDelivrPattern = RegExp(
r'.*raw\.githubusercontent\.com(/[^/]+)(/[^/]+)/(?:master|main)(/.*)');
final match = jsDelivrPattern.firstMatch(rawUrl);
if (match != null) {
final user = match.group(1);
final repo = match.group(2);
final path = match.group(3);
mirrorUrl = '${mirror.url}$user$repo$path';
}
} else if (mirror.type == GithubMirrorType.ghProxy) {
// gh-proxy supports both source and release
// Simply prepend the mirror URL to the original URL
mirrorUrl = '${mirror.url}/$rawUrl';
}
if (mirrorUrl != null) {
ret.add(mirrorUrl);
} }
ret.add("${cdn[0]}$matchStr");
} }
return ret; return ret;
} }
+1 -1
View File
@@ -15,7 +15,7 @@ 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. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.8.2+1 version: 1.8.3+1
environment: environment:
sdk: ">=3.0.0 <4.0.0" sdk: ">=3.0.0 <4.0.0"