Compare commits

..
20 Commits
Author SHA1 Message Date
Levi 4c2321d686 Merge branch 'main' into ci/test 2026-03-25 13:58:42 +08:00
Jaime Muñoz Martín daa8c39dad feat(lang): update Catalan language strings (#1322) 2026-03-23 11:01:59 +08:00
Levi 78e23217f9 FIX 2026-03-18 21:56:43 +08:00
Levi 61f62ec39a fix 2026-03-18 21:44:51 +08:00
Levi 5d76d062ad fix 2026-03-18 21:27:30 +08:00
Levi fc489b6b97 ci test 2026-03-18 21:16:03 +08:00
Levi a5cd53f94c release: v1.9.3 2026-03-18 20:00:59 +08:00
Levi 847e88881d fix: non-range HTTP downloaded bytes exceeding total file size (#1317) 2026-03-18 18:53:49 +08:00
Wang YiJia 3d20e818e5 fix: add IsCreated flag to reliably track task creation state (#1314) 2026-03-18 18:35:39 +08:00
Levi 09f7c88ed3 feat: ed2k protocol support (#1316) 2026-03-17 19:42:16 +08:00
Copilot f7b26753c1 docs: migrate docs.gopeed.com links to gopeed.com/docs (#1311) 2026-03-12 22:44:17 +08:00
Levi 9704d4af27 fix: update RPM icon path (#1309) 2026-03-12 21:33:31 +08:00
Jaime Muñoz Martín 1907f404ac feat(lang): Catalan language added (#1303) 2026-03-11 10:26:55 +08:00
Tuna Dinçer 72c5ba7d20 feat(lang): update Turkish translation (#1304) 2026-03-10 13:38:02 +08:00
Levi 5b0254f7c0 release: v1.9.2 2026-03-08 19:52:32 +08:00
Levi d36b345f92 feat: extension store (#1301) 2026-03-06 21:53:19 +08:00
Locon213 bc5c818eae feat(desktop): implement native desktop notifications for task completion and errors (#1293) 2026-03-01 11:12:40 +08:00
Levi 98bd3305b7 feat: update Flutter version to 3.41.2 (#1290) 2026-02-26 20:42:34 +08:00
Tuna Dinçer 3f38848051 feat: Turkish localization improvements 2026-02-25 09:54:15 +08:00
Levi 82d7f8ec81 feat: update debug extension IDs retrieval to use environment (#1281) 2026-02-14 17:25:46 +08:00
64 changed files with 3611 additions and 417 deletions

No files matched your search

+1 -1
View File
@@ -12,4 +12,4 @@
#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] #custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
# github: monkeyWie # github: monkeyWie
ko_fi: gopeed ko_fi: gopeed
custom: https://docs.gopeed.com/donate.html custom: https://gopeed.com/docs/donate
+4 -11
View File
@@ -14,7 +14,7 @@ on:
env: env:
GO_VERSION: "1.24" GO_VERSION: "1.24"
FLUTTER_VERSION: "3.38.7" FLUTTER_VERSION: "3.41.2"
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }} GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }} GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }}
@@ -187,7 +187,7 @@ jobs:
mv "Output\gopeed.exe" "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe" mv "Output\gopeed.exe" "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe"
Compress-Archive -Path "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe" -DestinationPath "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.zip" Compress-Archive -Path "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe" -DestinationPath "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.zip"
- name: Upload - name: Upload
uses: shogo82148/actions-upload-release-asset@v1 uses: shogo82148/actions-upload-release-asset@v1.9.2
with: with:
upload_url: ${{ needs.get-release.outputs.upload_url }} upload_url: ${{ needs.get-release.outputs.upload_url }}
asset_path: ui/flutter/build/windows/Output/* asset_path: ui/flutter/build/windows/Output/*
@@ -324,7 +324,7 @@ jobs:
channel: master channel: master
- run: | - run: |
sudo apt update -y sudo apt update -y
sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate imagemagick potrace libkeybinder-3.0-dev sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate libkeybinder-3.0-dev
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
@@ -339,18 +339,11 @@ jobs:
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) RPM_ARCH=$(uname -m)
mkdir -p linux/packaging/rpm mkdir -p linux/packaging/rpm
cat << EOF > linux/packaging/rpm/make_config.yaml cat << EOF > linux/packaging/rpm/make_config.yaml
display_name: Gopeed display_name: Gopeed
icon: $ICON_PATH icon: assets/icon/icon.svg
summary: High speed downloader. summary: High speed downloader.
description: A high speed downloader that supports all platforms. description: A high speed downloader that supports all platforms.
group: Applications/Internet group: Applications/Internet
+1 -1
View File
@@ -29,7 +29,7 @@ on:
env: env:
GO_VERSION: "1.24" GO_VERSION: "1.24"
FLUTTER_VERSION: "3.38.7" FLUTTER_VERSION: "3.41.2"
jobs: jobs:
# lint: # lint:
+6 -6
View File
@@ -1,10 +1,10 @@
# [![](_docs/img/banner.png)](https://gopeed.com) # [![](_docs/img/banner.svg)](https://gopeed.com)
[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest) [![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed) [![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
[![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://gopeed.com/docs/donate)
[![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) [![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)
@@ -16,9 +16,9 @@
## 🚀 Introduction ## 🚀 Introduction
Gopeed (full name Go Speed), a high-speed downloader developed by `Golang` + `Flutter`, supports (HTTP, BitTorrent, Magnet) protocol, and supports all platforms. In addition to basic download functions, Gopeed is also a highly customizable downloader that supports implementing more features through integration with [APIs](https://docs.gopeed.com/dev-api.html) or installation and development of [extensions](https://docs.gopeed.com/dev-extension.html). Gopeed (full name Go Speed), a high-speed downloader developed by `Golang` + `Flutter`, supports (HTTP, BitTorrent, Magnet, ED2K) protocol, and supports all platforms. In addition to basic download functions, Gopeed is also a highly customizable downloader that supports implementing more features through integration with [APIs](https://gopeed.com/docs/dev-api) or installation and development of [extensions](https://gopeed.com/docs/dev-extension).
Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://docs.gopeed.com) Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://gopeed.com/docs)
## ⬇️ Download ## ⬇️ Download
@@ -165,7 +165,7 @@ Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://
</tbody> </tbody>
</table> </table>
More about installation, please refer to [Installation](https://docs.gopeed.com/install.html) More about installation, please refer to [Installation](https://gopeed.com/docs/install)
### 🛠️ Command tool ### 🛠️ Command tool
@@ -187,7 +187,7 @@ Follow our WeChat Official Account to get the latest updates and news.
## 💝 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://gopeed.com/docs/donate) to support the development of this project, thank you!
## 🖼️ Showcase ## 🖼️ Showcase
+6 -6
View File
@@ -1,10 +1,10 @@
# [![](_docs/img/banner.png)](https://gopeed.com) # [![](_docs/img/banner.svg)](https://gopeed.com)
[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest) [![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed) [![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
[![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://gopeed.com/docs/donate)
[![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) [![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)
@@ -16,9 +16,9 @@
## 🚀 はじめに ## 🚀 はじめに
Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された高速ダウンローダーで、(HTTP、BitTorrent、Magnet) プロトコルをサポートし、すべてのプラットフォームをサポートします。基本的なダウンロード機能に加え、[APIs](https://docs.gopeed.com/dev-api.html)との連動や[拡張機能](https://docs.gopeed.com/dev-extension.html)のインストール・開発による追加機能にも対応した、カスタマイズ性の高いダウンローダーです。 Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された高速ダウンローダーで、(HTTP、BitTorrent、Magnet、ED2K) プロトコルをサポートし、すべてのプラットフォームをサポートします。基本的なダウンロード機能に加え、[APIs](https://gopeed.com/docs/dev-api)との連動や[拡張機能](https://gopeed.com/docs/dev-extension)のインストール・開発による追加機能にも対応した、カスタマイズ性の高いダウンローダーです。
見て下さい ✈ [公式ウェブサイト](https://gopeed.com) | 📖 [開発ドキュメント](https://docs.gopeed.com) 見て下さい ✈ [公式ウェブサイト](https://gopeed.com) | 📖 [開発ドキュメント](https://gopeed.com/docs)
## ⬇️ インストール ## ⬇️ インストール
@@ -156,7 +156,7 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
</tr> </tr>
</tbody> </tbody>
</table> </table>
インストールについての詳細は、[インストール](https://docs.gopeed.com/install.html)を参照してください。 インストールについての詳細は、[インストール](https://gopeed.com/docs/install)を参照してください。
### 🛠️ コマンドツール ### 🛠️ コマンドツール
@@ -168,7 +168,7 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
## 💝 寄付 ## 💝 寄付
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://docs.gopeed.com/donate.html)をご検討ください! もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://gopeed.com/docs/donate)をご検討ください!
## 🖼️ ショーケース ## 🖼️ ショーケース
+6 -6
View File
@@ -1,10 +1,10 @@
# [![](_docs/img/banner.png)](https://gopeed.com) # [![](_docs/img/banner.svg)](https://gopeed.com)
[![Trạng thái kiểm tra](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest) [![Trạng thái kiểm tra](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed) [![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
[![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://gopeed.com/docs/donate)
[![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) [![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)
@@ -16,9 +16,9 @@
## 🚀 Giới thiệu ## 🚀 Giới thiệu
Gopeed (tên đầy đủ Go Speed), một công cụ tải xuống tốc độ cao được phát triển bởi `Golang` + `Flutter`, hỗ trợ giao thức (HTTP, BitTorrent, Magnet) và hỗ trợ tất cả các nền tảng. Ngoài các chức năng tải xuống cơ bản, Gopeed còn là một công cụ tải xuống có thể tùy chỉnh cao cho phép triển khai thêm tính năng thông qua việc tích hợp với [APIs](https://docs.gopeed.com/dev-api.html) hoặc cài đặt và phát triển các [tiện ích mở rộng](https://docs.gopeed.com/dev-extension.html). Gopeed (tên đầy đủ Go Speed), một công cụ tải xuống tốc độ cao được phát triển bởi `Golang` + `Flutter`, hỗ trợ giao thức (HTTP, BitTorrent, Magnet, ED2K) và hỗ trợ tất cả các nền tảng. Ngoài các chức năng tải xuống cơ bản, Gopeed còn là một công cụ tải xuống có thể tùy chỉnh cao cho phép triển khai thêm tính năng thông qua việc tích hợp với [APIs](https://gopeed.com/docs/dev-api) hoặc cài đặt và phát triển các [tiện ích mở rộng](https://gopeed.com/docs/dev-extension).
Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu chính thức](https://docs.gopeed.com) Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu chính thức](https://gopeed.com/docs)
## ⬇️ Tải về ## ⬇️ Tải về
@@ -156,7 +156,7 @@ Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu
</tr> </tr>
</tbody> </tbody>
</table> </table>
Thêm thông tin về cài đặt, vui lòng tham khảo [Cài đặt](https://docs.gopeed.com/install.html) Thêm thông tin về cài đặt, vui lòng tham khảo [Cài đặt](https://gopeed.com/docs/install)
### 🛠️ Công cụ lệnh ### 🛠️ Công cụ lệnh
@@ -174,7 +174,7 @@ Theo dõi tài khoản chính thức để nhận các cập nhật và tin tứ
## 💝 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://gopeed.com/docs/donate) để hỗ trợ phát triển dự án này, cảm ơn bạn!
## 🖼️ Trưng bày ## 🖼️ Trưng bày
+6 -6
View File
@@ -1,10 +1,10 @@
# [![](_docs/img/banner.png)](https://gopeed.com) # [![](_docs/img/banner.svg)](https://gopeed.com)
[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest) [![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed) [![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
[![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://gopeed.com/docs/donate)
[![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) [![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)
@@ -16,9 +16,9 @@
## 🚀 介绍 ## 🚀 介绍
Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不是狗屁下载器!),是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet)协议下载,并且支持全平台使用。除了基本的下载功能外,Gopeed 还是一款高度可定制化的下载器,支持通过对接[APIs](https://docs.gopeed.com/zh/dev-api.html)或者安装和开发[扩展](https://docs.gopeed.com/zh/dev-extension.html)来实现更多的功能。 Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不是狗屁下载器!),是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet、ED2K)协议下载,并且支持全平台使用。除了基本的下载功能外,Gopeed 还是一款高度可定制化的下载器,支持通过对接[APIs](https://gopeed.com/docs/dev-api)或者安装和开发[扩展](https://gopeed.com/docs/dev-extension)来实现更多的功能。
访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [官方文档](https://docs.gopeed.com/zh/) 访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [官方文档](https://gopeed.com/docs)
## ⬇️ 下载 ## ⬇️ 下载
@@ -156,7 +156,7 @@ Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不
</tr> </tr>
</tbody> </tbody>
</table> </table>
更多关于安装的内容请参考[安装文档](https://docs.gopeed.com/zh/install.html) 更多关于安装的内容请参考[安装文档](https://gopeed.com/docs/install)
### 🛠️ 命令行工具 ### 🛠️ 命令行工具
@@ -178,7 +178,7 @@ Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、
## 💝 赞助 ## 💝 赞助
如果觉得项目对你有帮助,请考虑[赞助](https://docs.gopeed.com/zh/donate)以支持这个项目的发展,非常感谢! 如果觉得项目对你有帮助,请考虑[赞助](https://gopeed.com/docs/donate)以支持这个项目的发展,非常感谢!
## 🖼️ 界面展示 ## 🖼️ 界面展示
+6 -6
View File
@@ -1,10 +1,10 @@
# [![](_docs/img/banner.png)](https://gopeed.com) # [![](_docs/img/banner.svg)](https://gopeed.com)
[![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest) [![Test Status](https://github.com/GopeedLab/gopeed/workflows/test/badge.svg)](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed) [![Codecov](https://codecov.io/gh/GopeedLab/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/GopeedLab/gopeed)
[![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://gopeed.com/docs/donate)
[![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) [![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)
@@ -16,9 +16,9 @@
## 🚀 簡介 ## 🚀 簡介
Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速下載軟體,支援(HTTP、BitTorrent、Magnet)協定,同時支援所有的平台。 Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速下載軟體,支援(HTTP、BitTorrent、Magnet、ED2K)協定,同時支援所有的平台。
前往 ✈ [主頁](https://gopeed.com/zh-CN) | 📖 [文檔](https://docs.gopeed.com/zh/) 前往 ✈ [主頁](https://gopeed.com/zh-CN) | 📖 [文檔](https://gopeed.com/docs)
## ⬇️ 下載 ## ⬇️ 下載
@@ -157,7 +157,7 @@ Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速
</tbody> </tbody>
</table> </table>
更多關於安裝的內容請參考[安裝文檔](https://docs.gopeed.com/zh/install.html) 更多關於安裝的內容請參考[安裝文檔](https://gopeed.com/docs/install)
### 🛠️ 使用 CLI 安裝 ### 🛠️ 使用 CLI 安裝
@@ -175,7 +175,7 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
## 💝 贊助 ## 💝 贊助
如果你認為該項目對你有所幫助,請考慮[贊助](https://docs.gopeed.com/zh/donate)以支持該項目的持續發展,謝謝! 如果你認為該項目對你有所幫助,請考慮[贊助](https://gopeed.com/docs/donate)以支持該項目的持續發展,謝謝!
## 🖼️ 軟體介面 ## 🖼️ 軟體介面
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

+54
View File
@@ -0,0 +1,54 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 400" width="1200" height="400">
<rect width="1200" height="400" fill="#040605"/>
<g fill="#5dc76c">
<rect x="-100" y="50" width="150" height="2" opacity="0.6">
<animate attributeName="x" from="-200" to="1300" dur="0.8s" repeatCount="indefinite"/>
</rect>
<rect x="-200" y="150" width="200" height="1" opacity="0.4">
<animate attributeName="x" from="-300" to="1300" dur="1.2s" repeatCount="indefinite"/>
</rect>
<rect x="-50" y="250" width="180" height="3" opacity="0.8">
<animate attributeName="x" from="-200" to="1300" dur="0.6s" repeatCount="indefinite"/>
</rect>
<rect x="-150" y="350" width="220" height="2" opacity="0.5">
<animate attributeName="x" from="-300" to="1300" dur="1s" repeatCount="indefinite"/>
</rect>
<rect x="-100" y="100" width="100" height="1" opacity="0.7">
<animate attributeName="x" from="-150" to="1300" dur="0.9s" repeatCount="indefinite"/>
</rect>
<rect x="-250" y="300" width="250" height="2" opacity="0.3">
<animate attributeName="x" from="-400" to="1300" dur="1.5s" repeatCount="indefinite"/>
</rect>
</g>
<defs>
<filter id="logoGlow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="12" result="blur" />
<feComposite in="SourceGraphic" in2="blur" operator="over" />
</filter>
</defs>
<g transform="translate(600, 160)">
<!-- Floating animation -->
<g>
<animateTransform attributeName="transform" type="translate" values="0,-8; 0,8; 0,-8" dur="3s" repeatCount="indefinite" />
<!-- Pulsing glow behind logo -->
<circle cx="0" cy="0" r="45" fill="#5dc76c" filter="url(#logoGlow)" opacity="0.3">
<animate attributeName="opacity" values="0.1; 0.6; 0.1" dur="2s" repeatCount="indefinite" />
<animate attributeName="r" values="40; 65; 40" dur="2s" repeatCount="indefinite" />
</circle>
<!-- Breathing scale animation -->
<g>
<animateTransform attributeName="transform" type="scale" values="0.95; 1.05; 0.95" dur="2s" repeatCount="indefinite" />
<g transform="scale(0.12) translate(-512, -512)">
<path style="opacity:0.998" fill="#5dc76c" d="M 951.5,763.5 C 940.739,755.276 929.405,747.776 917.5,741C 908.477,736.319 899.143,732.485 889.5,729.5C 885.844,625.779 840.844,545.613 754.5,489C 674.802,441.453 590.135,430.12 500.5,455C 419.755,480.605 359.588,530.771 320,605.5C 278.585,565.816 230.752,555.649 176.5,575C 131.948,594.935 106.448,629.102 100,677.5C 99.3333,685.833 99.3333,694.167 100,702.5C 102.081,714.824 105.081,726.824 109,738.5C 95.4882,745.492 82.9882,753.826 71.5,763.5C 37.7121,705.639 16.5454,643.306 8,576.5C -9.41698,416.759 35.9164,278.093 144,160.5C 244.661,58.2827 367.161,5.94939 511.5,3.5C 678.975,7.48829 814.142,74.8216 917,205.5C 1011.33,336.426 1040,480.759 1003,638.5C 991.564,682.671 974.397,724.337 951.5,763.5 Z"/>
<path style="opacity:0.999" fill="#fefefe" d="M 951.5,763.5 C 949.28,768.272 946.78,772.938 944,777.5C 938.305,786.913 932.138,795.913 925.5,804.5C 915.954,796.888 905.288,791.055 893.5,787C 878.282,782.958 863.115,778.791 848,774.5C 854.013,716.891 841.346,663.891 810,615.5C 768.341,553.228 711.174,512.395 638.5,493C 560.311,479.566 490.311,497.566 428.5,547C 411.963,560.536 397.13,575.703 384,592.5C 366.745,616.923 348.911,640.923 330.5,664.5C 310.135,651.302 290.469,637.135 271.5,622C 238.448,602.307 206.782,604.307 176.5,628C 159.375,642.752 148.875,661.252 145,683.5C 143.968,692.716 144.968,701.716 148,710.5C 154.334,725.339 160.167,740.339 165.5,755.5C 138.118,764.688 114.451,779.688 94.5,800.5C 85.653,788.968 77.9863,776.635 71.5,763.5C 82.9882,753.826 95.4882,745.492 109,738.5C 105.081,726.824 102.081,714.824 100,702.5C 99.3333,694.167 99.3333,685.833 100,677.5C 106.448,629.102 131.948,594.935 176.5,575C 230.752,555.649 278.585,565.816 320,605.5C 359.588,530.771 419.755,480.605 500.5,455C 590.135,430.12 674.802,441.453 754.5,489C 840.844,545.613 885.844,625.779 889.5,729.5C 899.143,732.485 908.477,736.319 917.5,741C 929.405,747.776 940.739,755.276 951.5,763.5 Z"/>
<path style="opacity:0.998" fill="#5dc76c" d="M 925.5,804.5 C 850.791,908.608 750.791,975.775 625.5,1006C 609.308,1009.7 592.975,1012.53 576.5,1014.5C 615.708,969.938 655.042,925.438 694.5,881C 664.168,880.5 633.835,880.333 603.5,880.5C 603.5,811.167 603.5,741.833 603.5,672.5C 542.167,672.5 480.833,672.5 419.5,672.5C 419.5,741.5 419.5,810.5 419.5,879.5C 388.493,879.167 357.493,879.5 326.5,880.5C 326.624,881.107 326.957,881.44 327.5,881.5C 366.805,925.808 406.138,970.141 445.5,1014.5C 318.63,996.317 212.796,939.651 128,844.5C 115.85,830.525 104.683,815.859 94.5,800.5C 114.451,779.688 138.118,764.688 165.5,755.5C 160.167,740.339 154.334,725.339 148,710.5C 144.968,701.716 143.968,692.716 145,683.5C 148.875,661.252 159.375,642.752 176.5,628C 206.782,604.307 238.448,602.307 271.5,622C 290.469,637.135 310.135,651.302 330.5,664.5C 348.911,640.923 366.745,616.923 384,592.5C 397.13,575.703 411.963,560.536 428.5,547C 490.311,497.566 560.311,479.566 638.5,493C 711.174,512.395 768.341,553.228 810,615.5C 841.346,663.891 854.013,716.891 848,774.5C 863.115,778.791 878.282,782.958 893.5,787C 905.288,791.055 915.954,796.888 925.5,804.5 Z"/>
<path style="opacity:0.999" fill="#fefefe" d="M 576.5,1014.5 C 532.836,1019.82 489.169,1019.82 445.5,1014.5C 406.138,970.141 366.805,925.808 327.5,881.5C 358.167,880.833 388.833,880.167 419.5,879.5C 419.5,810.5 419.5,741.5 419.5,672.5C 480.833,672.5 542.167,672.5 603.5,672.5C 603.5,741.833 603.5,811.167 603.5,880.5C 633.835,880.333 664.168,880.5 694.5,881C 655.042,925.438 615.708,969.938 576.5,1014.5 Z"/>
<path style="opacity:1" fill="#ade2b5" d="M 419.5,879.5 C 388.833,880.167 358.167,880.833 327.5,881.5C 326.957,881.44 326.624,881.107 326.5,880.5C 357.493,879.5 388.493,879.167 419.5,879.5 Z"/>
</g>
</g>
</g>
</g>
<text x="600" y="300" font-family="sans-serif" font-size="72" font-style="italic" font-weight="900" text-anchor="middle" fill="#fff" letter-spacing="8">GOPEED</text>
</svg>

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+1
View File
@@ -45,6 +45,7 @@ require (
github.com/klauspost/pgzip v1.2.6 // indirect github.com/klauspost/pgzip v1.2.6 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/minlz v1.0.1 // indirect github.com/minio/minlz v1.0.1 // indirect
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447 // indirect
github.com/nwaples/rardecode/v2 v2.2.0 // indirect github.com/nwaples/rardecode/v2 v2.2.0 // indirect
github.com/onsi/ginkgo/v2 v2.23.4 // indirect github.com/onsi/ginkgo/v2 v2.23.4 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect
+4
View File
@@ -386,6 +386,10 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/monkeyWie/goed2k v0.0.0-20260317094144-6e18d43056e5 h1:tEOucyKJzFsCz5Gr41jFHj8i2g1zemTyS4uyErJgFHc=
github.com/monkeyWie/goed2k v0.0.0-20260317094144-6e18d43056e5/go.mod h1:Ry2y1QlzerUgA1hVmExBdXXzE4Sjk1M7w0nSh6dhDOg=
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447 h1:88kRsNwkDKPA4NzRUWdOoIL7SyMNDwtmAxzShKBvKTI=
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447/go.mod h1:Ry2y1QlzerUgA1hVmExBdXXzE4Sjk1M7w0nSh6dhDOg=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg= github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
+15
View File
@@ -127,6 +127,21 @@ type FetcherManager interface {
Close() error Close() error
} }
// StatefulFetcherManager is an optional extension for protocols that keep
// shared client state outside individual task fetchers.
type StatefulFetcherManager interface {
SetStateStore(store ProtocolStateStore)
}
// ProtocolStateStore persists shared protocol state for a fetcher manager.
// Downloader provides the concrete storage backend, while the protocol decides
// when state should be loaded or flushed.
type ProtocolStateStore interface {
Load(v any) (bool, error)
Save(v any) error
Delete() error
}
type DefaultFetcher struct { type DefaultFetcher struct {
Ctl *controller.Controller Ctl *controller.Controller
Meta *FetcherMeta Meta *FetcherMeta
+15
View File
@@ -0,0 +1,15 @@
package ed2k
const (
defaultServerList = "45.82.80.155:5687,176.123.5.89:4725,85.121.5.137:4232,176.123.2.239:4232,145.239.2.134:4661,91.208.162.87:4232,37.15.61.236:4232"
defaultServerMet = "ed2k://|serverlist|http://upd.emule-security.org/server.met|/"
defaultNodesDat = "https://upd.emule-security.org/nodes.dat"
)
type config struct {
ListenPort int `json:"listenPort"`
UDPPort int `json:"udpPort"`
ServerAddr string `json:"serverAddr"`
ServerMet string `json:"serverMet"`
NodesDat string `json:"nodesDat"`
}
File diff suppressed because it is too large. Load diff
+42
View File
@@ -0,0 +1,42 @@
package ed2k
import (
"testing"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/pkg/base"
)
const testLink = "ed2k://|file|cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso|4630972416|8867C5E54405FF9452225B66EFEE690A|/"
func TestFetcher_Resolve(t *testing.T) {
f := (&FetcherManager{}).Build()
f.Setup(controller.NewController())
err := f.Resolve(&base.Request{URL: testLink}, &base.Options{Path: t.TempDir()})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
meta := f.Meta()
if meta.Res == nil {
t.Fatal("Resolve() resource is nil")
}
if got, want := meta.Res.Hash, "8867C5E54405FF9452225B66EFEE690A"; got != want {
t.Fatalf("Resolve() hash = %s, want %s", got, want)
}
if got, want := meta.Res.Size, int64(4630972416); got != want {
t.Fatalf("Resolve() size = %d, want %d", got, want)
}
if got, want := meta.Res.Files[0].Name, "cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso"; got != want {
t.Fatalf("Resolve() name = %q, want %q", got, want)
}
}
func TestFetcherManager_ParseName(t *testing.T) {
got := (&FetcherManager{}).ParseName(testLink)
want := "cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso"
if got != want {
t.Fatalf("ParseName() = %q, want %q", got, want)
}
}
+33 -1
View File
@@ -618,6 +618,7 @@ func (f *Fetcher) doStart() error {
for _, conn := range f.connections { for _, conn := range f.connections {
// Reset connections that can be retried // Reset connections that can be retried
if !conn.Completed && conn.State != connCompleted { if !conn.Completed && conn.State != connCompleted {
f.resetConnectionForRestart(conn)
conn.State = connNotStarted conn.State = connNotStarted
conn.failed = false conn.failed = false
conn.retryTimes = 0 conn.retryTimes = 0
@@ -1413,6 +1414,27 @@ func (f *Fetcher) helpOtherConnection(helper *connection) bool {
return true return true
} }
func (f *Fetcher) resetConnectionForRestart(conn *connection) {
if f.meta.Res.Range {
return
}
// Without range support a new request always starts from byte 0,
// so pause/retry must restart instead of continuing from the old offset.
if conn.Chunk == nil {
conn.Chunk = newChunk(0, 0)
} else {
conn.Chunk.Begin = 0
conn.Chunk.End = 0
conn.Chunk.Downloaded = 0
}
conn.Downloaded = 0
conn.Completed = false
conn.speed = 0
conn.lastSpeedCheck = 0
conn.lastSpeedDownload = 0
}
func (f *Fetcher) resumeConnections() { func (f *Fetcher) resumeConnections() {
// Collect connections to resume while holding the lock // Collect connections to resume while holding the lock
var toResume []*connection var toResume []*connection
@@ -1436,6 +1458,7 @@ func (f *Fetcher) resumeConnections() {
continue continue
} }
} }
f.resetConnectionForRestart(conn)
// Reset the connection state for resume // Reset the connection state for resume
conn.ctx, conn.cancel = context.WithCancel(f.ctx) conn.ctx, conn.cancel = context.WithCancel(f.ctx)
conn.State = connNotStarted conn.State = connNotStarted
@@ -1475,7 +1498,16 @@ func (f *Fetcher) onDownloadComplete() {
// Check if all chunks are complete (no remaining bytes) // Check if all chunks are complete (no remaining bytes)
allChunksComplete := true allChunksComplete := true
for _, conn := range f.connections { for _, conn := range f.connections {
if conn.Chunk != nil && conn.Chunk.remain() > 0 && !conn.Completed && conn.State != connCompleted { needsMoreData := false
if f.meta.Res.Range {
needsMoreData = conn.Chunk != nil && conn.Chunk.remain() > 0
} else if f.meta.Res.Size > 0 {
needsMoreData = conn.Downloaded < f.meta.Res.Size
} else {
needsMoreData = !conn.Completed && conn.State != connCompleted
}
if needsMoreData && !conn.Completed && conn.State != connCompleted {
// This connection has remaining work and isn't done // This connection has remaining work and isn't done
// Check if it failed with 403 (server limit) - these can be ignored if other connections completed the work // Check if it failed with 403 (server limit) - these can be ignored if other connections completed the work
if conn.State == connFailed && conn.failed { if conn.State == connFailed && conn.failed {
+47
View File
@@ -299,6 +299,53 @@ func TestFetcher_DownloadContinue(t *testing.T) {
downloadContinue(listener, 16, t) downloadContinue(listener, 16, t)
} }
func TestFetcher_DownloadContinue_NoRangeRestart(t *testing.T) {
listener := test.StartTestNoRangeSlowServer(time.Millisecond)
defer listener.Close()
fetcher := downloadReady(listener, 4, t)
if err := fetcher.Start(); err != nil {
t.Fatal(err)
}
time.Sleep(20 * time.Millisecond)
stats := fetcher.Stats().(*http.Stats)
if len(stats.Connections) != 1 {
t.Fatalf("expected a single non-range connection, got %d", len(stats.Connections))
}
if stats.Connections[0].Downloaded <= 0 || stats.Connections[0].Downloaded >= test.BuildSize {
t.Fatalf("expected partial download before pause, got %d", stats.Connections[0].Downloaded)
}
if err := fetcher.Pause(); err != nil {
t.Fatal(err)
}
if err := fetcher.Start(); err != nil {
t.Fatal(err)
}
if err := fetcher.Wait(); err != nil {
t.Fatal(err)
}
finalStats := fetcher.Stats().(*http.Stats)
if len(finalStats.Connections) != 1 {
t.Fatalf("expected a single non-range connection after resume, got %d", len(finalStats.Connections))
}
if finalStats.Connections[0].Downloaded != test.BuildSize {
t.Fatalf("downloaded bytes should restart cleanly: got %d, want %d", finalStats.Connections[0].Downloaded, test.BuildSize)
}
if total := fetcher.Progress().TotalDownloaded(); total != test.BuildSize {
t.Fatalf("progress total = %d, want %d", total, test.BuildSize)
}
want := test.FileMd5(test.BuildFile)
got := test.FileMd5(test.DownloadFile)
if want != got {
t.Errorf("Download() got = %v, want %v", got, want)
}
}
func TestFetcher_DownloadChunked(t *testing.T) { func TestFetcher_DownloadChunked(t *testing.T) {
listener := test.StartTestCustomServer() listener := test.StartTestCustomServer()
defer listener.Close() defer listener.Close()
+37
View File
@@ -335,6 +335,43 @@ func StartTestOneTimeServer() net.Listener {
}) })
} }
// StartTestNoRangeSlowServer creates a server that always returns the full file
// with Content-Length but does not support Range requests.
func StartTestNoRangeSlowServer(delayPerChunk time.Duration) net.Listener {
return startTestServer(func(sl *shutdownListener) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
file, err := os.Open(BuildFile)
if err != nil {
return
}
defer file.Close()
buf := make([]byte, 256*1024)
for !sl.isShutdown {
n, readErr := file.Read(buf)
if n > 0 {
if _, writeErr := writer.Write(buf[:n]); writeErr != nil {
return
}
if flusher, ok := writer.(http.Flusher); ok {
flusher.Flush()
}
if delayPerChunk > 0 {
time.Sleep(delayPerChunk)
}
}
if readErr != nil {
return
}
}
})
return mux
})
}
// StartTestExpiringRedirectServer creates a server that simulates expiring redirect URLs. // StartTestExpiringRedirectServer creates a server that simulates expiring redirect URLs.
// The original URL redirects to a temporary URL that expires after a specified number of requests. // The original URL redirects to a temporary URL that expires after a specified number of requests.
// When the temporary URL expires (returns 403), the client should retry with the original URL // When the temporary URL expires (returns 403), the client should retry with the original URL
+46 -6
View File
@@ -31,6 +31,8 @@ const (
bucketTask = "task" bucketTask = "task"
// task download data bucket // task download data bucket
bucketSave = "save" bucketSave = "save"
// protocol-level shared client state bucket
bucketProtocolState = "protocol_state"
// downloader config bucket // downloader config bucket
bucketConfig = "config" bucketConfig = "config"
// downloader extension bucket // downloader extension bucket
@@ -141,7 +143,7 @@ func NewDownloader(cfg *DownloaderConfig) *Downloader {
func (d *Downloader) Setup() error { func (d *Downloader) Setup() error {
// setup storage // setup storage
if err := d.storage.Setup([]string{bucketTask, bucketSave, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil { if err := d.storage.Setup([]string{bucketTask, bucketSave, bucketProtocolState, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil {
return err return err
} }
// load config from storage // load config from storage
@@ -165,6 +167,12 @@ func (d *Downloader) Setup() error {
if _, ok := d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol]; !ok { if _, ok := d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol]; !ok {
d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol] = fm.DefaultConfig() d.cfg.DownloaderStoreConfig.ProtocolConfig[protocol] = fm.DefaultConfig()
} }
if sfm, ok := fm.(fetcher.StatefulFetcherManager); ok {
sfm.SetStateStore(&protocolStateStore{
storage: d.storage,
protocol: protocol,
})
}
} }
// load tasks from storage // load tasks from storage
@@ -358,10 +366,17 @@ func (d *Downloader) saveTask(task *Task) error {
d.Logger.Error().Stack().Err(err).Msgf("serialize fetcher failed: %s", task.ID) d.Logger.Error().Stack().Err(err).Msgf("serialize fetcher failed: %s", task.ID)
return err return err
} }
if data != nil {
if err := d.storage.Put(bucketSave, task.ID, data); err != nil { if err := d.storage.Put(bucketSave, task.ID, data); err != nil {
d.Logger.Error().Stack().Err(err).Msgf("persist fetcher failed: %s", task.ID) d.Logger.Error().Stack().Err(err).Msgf("persist fetcher failed: %s", task.ID)
return err return err
} }
} else {
if err := d.storage.Delete(bucketSave, task.ID); err != nil {
d.Logger.Error().Stack().Err(err).Msgf("clear fetcher state failed: %s", task.ID)
return err
}
}
if err := d.storage.Put(bucketTask, task.ID, task); err != nil { if err := d.storage.Put(bucketTask, task.ID, task); err != nil {
d.Logger.Error().Stack().Err(err).Msgf("persist task failed: %s", task.ID) d.Logger.Error().Stack().Err(err).Msgf("persist task failed: %s", task.ID)
return err return err
@@ -820,6 +835,26 @@ func (d *Downloader) Clear() error {
return nil return nil
} }
type protocolStateStore struct {
storage Storage
protocol string
}
func (s *protocolStateStore) Load(v any) (bool, error) {
return s.storage.Get(bucketProtocolState, s.protocol, v)
}
func (s *protocolStateStore) Save(v any) error {
if v == nil {
return s.Delete()
}
return s.storage.Put(bucketProtocolState, s.protocol, v)
}
func (s *protocolStateStore) Delete() error {
return s.storage.Delete(bucketProtocolState, s.protocol)
}
func (d *Downloader) Listener(fn Listener) { func (d *Downloader) Listener(fn Listener) {
d.listener = fn d.listener = fn
} }
@@ -1186,7 +1221,7 @@ func (d *Downloader) statusMut(task *Task, fn func() (bool, error)) (bool, error
} }
func (d *Downloader) doStart(task *Task) (err error) { func (d *Downloader) doStart(task *Task) (err error) {
var isCreate bool var needCreate bool
isReturn, err := d.statusMut(task, func() (isReturn bool, err error) { isReturn, err := d.statusMut(task, func() (isReturn bool, err error) {
if task.Status == base.DownloadStatusRunning || task.Status == base.DownloadStatusDone { if task.Status == base.DownloadStatusRunning || task.Status == base.DownloadStatusDone {
isReturn = true isReturn = true
@@ -1198,7 +1233,7 @@ func (d *Downloader) doStart(task *Task) (err error) {
d.Logger.Error().Stack().Err(err).Msgf("restore fetcher failed, task id: %s", task.ID) d.Logger.Error().Stack().Err(err).Msgf("restore fetcher failed, task id: %s", task.ID)
return return
} }
isCreate = task.Status == base.DownloadStatusReady needCreate = !task.IsCreated
task.updateStatus(base.DownloadStatusRunning) task.updateStatus(base.DownloadStatusRunning)
return return
@@ -1224,7 +1259,7 @@ func (d *Downloader) doStart(task *Task) (err error) {
task.Meta.Res = task.fetcher.Meta().Res task.Meta.Res = task.fetcher.Meta().Res
} }
if isCreate { if needCreate {
if task.fetcherManager.AutoRename() { if task.fetcherManager.AutoRename() {
d.checkDuplicateLock.Lock() d.checkDuplicateLock.Lock()
defer d.checkDuplicateLock.Unlock() defer d.checkDuplicateLock.Unlock()
@@ -1248,10 +1283,9 @@ func (d *Downloader) doStart(task *Task) (err error) {
task.Meta.Opts.Name = newName task.Meta.Opts.Name = newName
} }
} }
task.IsCreated = true
task.Meta.Res.CalcSize(task.Meta.Opts.SelectFiles) task.Meta.Res.CalcSize(task.Meta.Opts.SelectFiles)
} }
task.Progress.Speed = 0 task.Progress.Speed = 0
task.timer.Start() task.timer.Start()
if err := task.fetcher.Start(); err != nil { if err := task.fetcher.Start(); err != nil {
@@ -1300,9 +1334,15 @@ func (d *Downloader) doPause(task *Task) (err error) {
return err return err
} }
} }
if task.fetcherManager != nil && task.fetcher != nil {
if err := d.saveTask(task); err != nil {
return err
}
} else {
if err := d.storage.Put(bucketTask, task.ID, task.clone()); err != nil { if err := d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {
return err return err
} }
}
d.emit(EventKeyPause, task) d.emit(EventKeyPause, task)
return nil return nil
} }
+12
View File
@@ -8,6 +8,7 @@ import (
"github.com/GopeedLab/gopeed/internal/controller" "github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/protocol/bt" "github.com/GopeedLab/gopeed/internal/protocol/bt"
"github.com/GopeedLab/gopeed/internal/protocol/ed2k"
"github.com/GopeedLab/gopeed/internal/protocol/http" "github.com/GopeedLab/gopeed/internal/protocol/http"
"github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/util" "github.com/GopeedLab/gopeed/pkg/util"
@@ -26,6 +27,7 @@ type Task struct {
Status base.Status `json:"status"` Status base.Status `json:"status"`
Uploading bool `json:"uploading"` Uploading bool `json:"uploading"`
Progress *Progress `json:"progress"` Progress *Progress `json:"progress"`
IsCreated bool `json:"isCreated"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
@@ -48,6 +50,7 @@ func NewTask() *Task {
Status: base.DownloadStatusReady, Status: base.DownloadStatusReady,
CreatedAt: time.Now(), CreatedAt: time.Now(),
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
IsCreated: false,
} }
} }
@@ -110,6 +113,14 @@ func (t *Task) updateUploadSpeed(downloaded int64, usedTime float64) int64 {
} }
func calcSpeed(speedArr *[]int64, downloaded int64, usedTime float64) int64 { func calcSpeed(speedArr *[]int64, downloaded int64, usedTime float64) int64 {
if usedTime <= 0 {
return 0
}
if downloaded < 0 {
*speedArr = (*speedArr)[:0]
return 0
}
*speedArr = append(*speedArr, downloaded) *speedArr = append(*speedArr, downloaded)
// Record last 5 seconds of download speed to calculate the average speed // Record last 5 seconds of download speed to calculate the average speed
if len(*speedArr) > int(5.0/usedTime) { if len(*speedArr) > int(5.0/usedTime) {
@@ -156,6 +167,7 @@ func (cfg *DownloaderConfig) Init() *DownloaderConfig {
cfg.FetchManagers = []fetcher.FetcherManager{ cfg.FetchManagers = []fetcher.FetcherManager{
new(http.FetcherManager), new(http.FetcherManager),
new(bt.FetcherManager), new(bt.FetcherManager),
new(ed2k.FetcherManager),
} }
} }
if cfg.RefreshInterval == 0 { if cfg.RefreshInterval == 0 {
+18
View File
@@ -0,0 +1,18 @@
package download
import "testing"
func TestCalcSpeedResetOnRollback(t *testing.T) {
speedArr := []int64{1024, 2048, 4096}
if got := calcSpeed(&speedArr, -512, 1); got != 0 {
t.Fatalf("calcSpeed() = %d, want 0 after rollback", got)
}
if len(speedArr) != 0 {
t.Fatalf("speed window len = %d, want 0 after rollback", len(speedArr))
}
if got := calcSpeed(&speedArr, 1024, 1); got != 1024 {
t.Fatalf("calcSpeed() = %d, want 1024 after reset", got)
}
}
+14
View File
@@ -0,0 +1,14 @@
package ed2k
type Stats struct {
State string `json:"state"`
Paused bool `json:"paused"`
ActivePeers int `json:"activePeers"`
TotalPeers int `json:"totalPeers"`
DownloadRate int `json:"downloadRate"`
Upload int64 `json:"upload"`
UploadRate int `json:"uploadRate"`
TotalDone int64 `json:"totalDone"`
TotalReceived int64 `json:"totalReceived"`
TotalWanted int64 `json:"totalWanted"`
}
+2
View File
@@ -28,6 +28,7 @@ android {
ndkVersion flutter.ndkVersion ndkVersion flutter.ndkVersion
compileOptions { compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17
} }
@@ -83,4 +84,5 @@ flutter {
dependencies { dependencies {
implementation(name: 'libgopeed', ext: 'aar') implementation(name: 'libgopeed', ext: 'aar')
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
} }
+3 -3
View File
@@ -200,10 +200,10 @@ Future<void> putConfig(DownloaderConfig config) async {
return _parse(() => _client.dio.put("api/v1/config", data: config), null); return _parse(() => _client.dio.put("api/v1/config", data: config), null);
} }
Future<void> installExtension(InstallExtension installExtension) async { Future<String> installExtension(InstallExtension installExtension) async {
return _parse( return _parse<String>(
() => _client.dio.post("api/v1/extensions", data: installExtension), () => _client.dio.post("api/v1/extensions", data: installExtension),
null); (data) => data as String);
} }
Future<List<Extension>> getExtensions() async { Future<List<Extension>> getExtensions() async {
+55
View File
@@ -0,0 +1,55 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'api.dart';
import 'model/store_extension.dart';
class GopeedSiteApi {
GopeedSiteApi._();
static final instance = GopeedSiteApi._();
static const _host = 'gopeed.com';
Future<Map<String, dynamic>> getRelease() async {
final json = await _getJson('/api/release');
return json as Map<String, dynamic>;
}
Future<StoreExtensionPage> getExtensions({
int page = 1,
int limit = 20,
StoreExtensionSort sort = StoreExtensionSort.stars,
StoreSortOrder order = StoreSortOrder.desc,
String? query,
}) async {
final json = await _getJson('/api/extensions', queryParameters: {
'page': page.toString(),
'limit': limit.clamp(1, 100).toString(),
'sort': sort.name,
'order': order.name,
if (query != null && query.trim().isNotEmpty) 'q': query.trim(),
});
return StoreExtensionPage.fromJson(json as Map<String, dynamic>);
}
Future<void> reportExtensionInstall(String id) async {
final uri = Uri.https(_host, '/api/extensions/install');
await proxyRequest(
uri.toString(),
data: {'id': id},
options: Options(method: 'POST', contentType: Headers.jsonContentType),
);
}
Future<dynamic> _getJson(String path,
{Map<String, String>? queryParameters}) async {
final uri = Uri.https(_host, path, queryParameters);
final Response<String> response = await proxyRequest(uri.toString());
if (response.data == null || response.data!.isEmpty) {
throw Exception('Empty response from $uri');
}
return jsonDecode(response.data!);
}
}
@@ -31,6 +31,7 @@ class DownloaderConfig {
class ProtocolConfig { class ProtocolConfig {
HttpConfig http = HttpConfig(); HttpConfig http = HttpConfig();
BtConfig bt = BtConfig(); BtConfig bt = BtConfig();
Ed2kConfig ed2k = Ed2kConfig();
ProtocolConfig(); ProtocolConfig();
@@ -80,6 +81,28 @@ class BtConfig {
Map<String, dynamic> toJson() => _$BtConfigToJson(this); Map<String, dynamic> toJson() => _$BtConfigToJson(this);
} }
@JsonSerializable()
class Ed2kConfig {
int listenPort;
int udpPort;
String serverAddr;
String serverMet;
String nodesDat;
Ed2kConfig({
this.listenPort = 0,
this.udpPort = 0,
this.serverAddr = '',
this.serverMet = '',
this.nodesDat = '',
});
factory Ed2kConfig.fromJson(Map<String, dynamic> json) =>
_$Ed2kConfigFromJson(json);
Map<String, dynamic> toJson() => _$Ed2kConfigToJson(this);
}
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class ExtraConfig { class ExtraConfig {
String themeMode; String themeMode;
@@ -89,6 +112,7 @@ class ExtraConfig {
bool defaultBtClient; bool defaultBtClient;
bool notifyWhenNewVersion; bool notifyWhenNewVersion;
bool autoStartTasks; bool autoStartTasks;
bool desktopNotification;
List<DownloadCategory> downloadCategories; List<DownloadCategory> downloadCategories;
ExtraConfigBt bt = ExtraConfigBt(); ExtraConfigBt bt = ExtraConfigBt();
@@ -102,6 +126,7 @@ class ExtraConfig {
this.defaultBtClient = true, this.defaultBtClient = true,
this.notifyWhenNewVersion = true, this.notifyWhenNewVersion = true,
this.autoStartTasks = false, this.autoStartTasks = false,
this.desktopNotification = true,
this.downloadCategories = const [], this.downloadCategories = const [],
}); });
@@ -19,8 +19,7 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>) ..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)
..webhook = ..webhook =
WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?) WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?)
..script = ..script = ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?)
ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?)
..autoTorrent = AutoTorrentConfig.fromJson( ..autoTorrent = AutoTorrentConfig.fromJson(
json['autoTorrent'] as Map<String, dynamic>?) json['autoTorrent'] as Map<String, dynamic>?)
..archive = ..archive =
@@ -43,12 +42,15 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) =>
ProtocolConfig _$ProtocolConfigFromJson(Map<String, dynamic> json) => ProtocolConfig _$ProtocolConfigFromJson(Map<String, dynamic> json) =>
ProtocolConfig() ProtocolConfig()
..http = HttpConfig.fromJson(json['http'] as Map<String, dynamic>) ..http = HttpConfig.fromJson(json['http'] as Map<String, dynamic>)
..bt = BtConfig.fromJson(json['bt'] as Map<String, dynamic>); ..bt = BtConfig.fromJson(json['bt'] as Map<String, dynamic>)
..ed2k = Ed2kConfig.fromJson(
json['ed2k'] as Map<String, dynamic>? ?? <String, dynamic>{});
Map<String, dynamic> _$ProtocolConfigToJson(ProtocolConfig instance) => Map<String, dynamic> _$ProtocolConfigToJson(ProtocolConfig instance) =>
<String, dynamic>{ <String, dynamic>{
'http': instance.http.toJson(), 'http': instance.http.toJson(),
'bt': instance.bt.toJson(), 'bt': instance.bt.toJson(),
'ed2k': instance.ed2k.toJson(),
}; };
HttpConfig _$HttpConfigFromJson(Map<String, dynamic> json) => HttpConfig( HttpConfig _$HttpConfigFromJson(Map<String, dynamic> json) => HttpConfig(
@@ -83,6 +85,23 @@ Map<String, dynamic> _$BtConfigToJson(BtConfig instance) => <String, dynamic>{
'seedTime': instance.seedTime, 'seedTime': instance.seedTime,
}; };
Ed2kConfig _$Ed2kConfigFromJson(Map<String, dynamic> json) => Ed2kConfig(
listenPort: (json['listenPort'] as num?)?.toInt() ?? 0,
udpPort: (json['udpPort'] as num?)?.toInt() ?? 0,
serverAddr: json['serverAddr'] as String? ?? '',
serverMet: json['serverMet'] as String? ?? '',
nodesDat: json['nodesDat'] as String? ?? '',
);
Map<String, dynamic> _$Ed2kConfigToJson(Ed2kConfig instance) =>
<String, dynamic>{
'listenPort': instance.listenPort,
'udpPort': instance.udpPort,
'serverAddr': instance.serverAddr,
'serverMet': instance.serverMet,
'nodesDat': instance.nodesDat,
};
ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig( ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
themeMode: json['themeMode'] as String? ?? '', themeMode: json['themeMode'] as String? ?? '',
locale: json['locale'] as String? ?? '', locale: json['locale'] as String? ?? '',
@@ -91,6 +110,7 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
defaultBtClient: json['defaultBtClient'] as bool? ?? true, defaultBtClient: json['defaultBtClient'] as bool? ?? true,
notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true, notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
autoStartTasks: json['autoStartTasks'] as bool? ?? false, autoStartTasks: json['autoStartTasks'] as bool? ?? false,
desktopNotification: json['desktopNotification'] as bool? ?? true,
downloadCategories: (json['downloadCategories'] as List<dynamic>?) downloadCategories: (json['downloadCategories'] as List<dynamic>?)
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>)) ?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
.toList() ?? .toList() ??
@@ -109,6 +129,7 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
'defaultBtClient': instance.defaultBtClient, 'defaultBtClient': instance.defaultBtClient,
'notifyWhenNewVersion': instance.notifyWhenNewVersion, 'notifyWhenNewVersion': instance.notifyWhenNewVersion,
'autoStartTasks': instance.autoStartTasks, 'autoStartTasks': instance.autoStartTasks,
'desktopNotification': instance.desktopNotification,
'downloadCategories': 'downloadCategories':
instance.downloadCategories.map((e) => e.toJson()).toList(), instance.downloadCategories.map((e) => e.toJson()).toList(),
'bt': instance.bt.toJson(), 'bt': instance.bt.toJson(),
@@ -156,8 +177,7 @@ Map<String, dynamic> _$WebhookConfigToJson(WebhookConfig instance) =>
'urls': instance.urls, 'urls': instance.urls,
}; };
ScriptConfig _$ScriptConfigFromJson(Map<String, dynamic> json) => ScriptConfig _$ScriptConfigFromJson(Map<String, dynamic> json) => ScriptConfig(
ScriptConfig(
enable: json['enable'] as bool? ?? false, enable: json['enable'] as bool? ?? false,
paths: paths:
(json['paths'] as List<dynamic>?)?.map((e) => e as String).toList() ?? (json['paths'] as List<dynamic>?)?.map((e) => e as String).toList() ??
@@ -0,0 +1,149 @@
import 'dart:convert';
class StoreExtensionPage {
final List<StoreExtension> data;
final StorePagination pagination;
StoreExtensionPage({required this.data, required this.pagination});
factory StoreExtensionPage.fromJson(Map<String, dynamic> json) {
return StoreExtensionPage(
data: (json['data'] as List<dynamic>? ?? [])
.map((e) => StoreExtension.fromJson(e as Map<String, dynamic>))
.toList(),
pagination: StorePagination.fromJson(
json['pagination'] as Map<String, dynamic>? ?? {}),
);
}
}
class StorePagination {
final int page;
final int limit;
final int total;
final int totalPages;
final bool hasNext;
final bool hasPrev;
StorePagination({
required this.page,
required this.limit,
required this.total,
required this.totalPages,
required this.hasNext,
required this.hasPrev,
});
factory StorePagination.fromJson(Map<String, dynamic> json) {
return StorePagination(
page: (json['page'] as num?)?.toInt() ?? 1,
limit: (json['limit'] as num?)?.toInt() ?? 20,
total: (json['total'] as num?)?.toInt() ?? 0,
totalPages: (json['totalPages'] as num?)?.toInt() ?? 1,
hasNext: json['hasNext'] as bool? ?? false,
hasPrev: json['hasPrev'] as bool? ?? false,
);
}
}
class StoreExtension {
final String id;
final String repoFullName;
final String repoUrl;
final String? directory;
final String? commitSha;
final String name;
final String author;
final String title;
final String description;
final String? icon;
final String version;
final String? homepage;
final String? readme;
final int installCount;
final int stars;
final List<String> topics;
final DateTime? createdAt;
final DateTime? updatedAt;
StoreExtension({
required this.id,
required this.repoFullName,
required this.repoUrl,
this.directory,
this.commitSha,
required this.name,
required this.author,
required this.title,
required this.description,
this.icon,
required this.version,
this.homepage,
this.readme,
required this.installCount,
required this.stars,
required this.topics,
this.createdAt,
this.updatedAt,
});
factory StoreExtension.fromJson(Map<String, dynamic> json) {
return StoreExtension(
id: json['id'] as String? ?? '',
repoFullName: json['repoFullName'] as String? ?? '',
repoUrl: json['repoUrl'] as String? ?? '',
directory: json['directory'] as String?,
commitSha: json['commitSha'] as String?,
name: json['name'] as String? ?? '',
author: json['author'] as String? ?? '',
title: json['title'] as String? ?? '',
description: json['description'] as String? ?? '',
icon: json['icon'] as String?,
version: json['version'] as String? ?? '0.0.0',
homepage: json['homepage'] as String?,
readme: json['readme'] as String?,
installCount: (json['installCount'] as num?)?.toInt() ?? 0,
stars: (json['stars'] as num?)?.toInt() ?? 0,
topics: _parseTopics(json['topics']),
createdAt: _parseDate(json['createdAt']),
updatedAt: _parseDate(json['updatedAt']),
);
}
static List<String> _parseTopics(dynamic value) {
if (value is List) {
return value.map((e) => e.toString()).toList();
}
if (value is String && value.isNotEmpty) {
try {
final parsed = jsonDecode(value);
if (parsed is List) {
return parsed.map((e) => e.toString()).toList();
}
} catch (_) {}
}
return const [];
}
static DateTime? _parseDate(dynamic value) {
if (value == null) return null;
if (value is int) {
return DateTime.fromMillisecondsSinceEpoch(value);
}
if (value is String && value.isNotEmpty) {
return DateTime.tryParse(value);
}
return null;
}
}
enum StoreExtensionSort {
stars,
installs,
updated,
}
enum StoreSortOrder {
asc,
desc,
}
+1 -1
View File
@@ -6,7 +6,7 @@ part 'task.g.dart';
enum Status { ready, running, pause, wait, error, done } enum Status { ready, running, pause, wait, error, done }
enum Protocol { http, bt } enum Protocol { http, bt, ed2k }
// ExtractStatus enum matching Go backend // ExtractStatus enum matching Go backend
enum ExtractStatus { enum ExtractStatus {
+1
View File
@@ -51,6 +51,7 @@ const _$StatusEnumMap = {
const _$ProtocolEnumMap = { const _$ProtocolEnumMap = {
Protocol.http: 'http', Protocol.http: 'http',
Protocol.bt: 'bt', Protocol.bt: 'bt',
Protocol.ed2k: 'ed2k',
}; };
Progress _$ProgressFromJson(Map<String, dynamic> json) => Progress( Progress _$ProgressFromJson(Map<String, dynamic> json) => Progress(
@@ -17,6 +17,7 @@ import 'package:uri_to_file/uri_to_file.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import '../../../../api/api.dart'; import '../../../../api/api.dart';
import '../../../../api/model/create_task.dart'; import '../../../../api/model/create_task.dart';
import '../../../../api/model/downloader_config.dart'; import '../../../../api/model/downloader_config.dart';
@@ -38,6 +39,7 @@ import '../../../../util/util.dart';
import '../../../routes/app_pages.dart'; import '../../../routes/app_pages.dart';
import '../../../rpc/rpc.dart'; import '../../../rpc/rpc.dart';
import '../../redirect/views/redirect_view.dart'; import '../../redirect/views/redirect_view.dart';
import '../../../services/notification_service.dart';
const unixSocketPath = 'gopeed.sock'; const unixSocketPath = 'gopeed.sock';
@@ -97,6 +99,10 @@ class AppController extends GetxController with WindowListener, TrayListener {
_initWindows().onError((error, stackTrace) => _initWindows().onError((error, stackTrace) =>
logger.w("initWindows error", error, stackTrace)); logger.w("initWindows error", error, stackTrace));
if (Util.isDesktop()) {
Get.put(NotificationService());
}
_initTray().onError( _initTray().onError(
(error, stackTrace) => logger.w("initTray error", error, stackTrace)); (error, stackTrace) => logger.w("initTray error", error, stackTrace));
@@ -291,7 +297,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
MenuItem( MenuItem(
label: 'donate'.tr, label: 'donate'.tr,
onClick: (menuItem) => { onClick: (menuItem) => {
launchUrl(Uri.parse("https://docs.gopeed.com/donate.html"), launchUrl(Uri.parse("https://gopeed.com/docs/donate"),
mode: LaunchMode.externalApplication) mode: LaunchMode.externalApplication)
}, },
), ),
@@ -59,7 +59,7 @@ class CreateView extends GetView<CreateController> {
final _btTrackerController = TextEditingController(); final _btTrackerController = TextEditingController();
final _archivePasswordController = TextEditingController(); final _archivePasswordController = TextEditingController();
final _availableSchemes = ["http:", "https:", "magnet:"]; final _availableSchemes = ["http:", "https:", "magnet:", "ed2k:"];
final _skipVerifyCertController = false.obs; final _skipVerifyCertController = false.obs;
final _autoTorrentController = Rxn<bool>(); final _autoTorrentController = Rxn<bool>();
@@ -116,6 +116,7 @@ class CreateView extends GetView<CreateController> {
jsonDecode(jsonEncode(routerParams.req!.extra))); jsonDecode(jsonEncode(routerParams.req!.extra)));
_btTrackerController.text = reqExtra.trackers.join("\n"); _btTrackerController.text = reqExtra.trackers.join("\n");
}, },
Protocol.ed2k: null,
}; };
if (routerParams.req?.extra != null) { if (routerParams.req?.extra != null) {
extraHandlers[protocol]?.call(); extraHandlers[protocol]?.call();
@@ -139,6 +140,7 @@ class CreateView extends GetView<CreateController> {
} }
}, },
Protocol.bt: null, Protocol.bt: null,
Protocol.ed2k: null,
}; };
if (routerParams.opts?.extra != null) { if (routerParams.opts?.extra != null) {
optionsHandlers[protocol]?.call(); optionsHandlers[protocol]?.call();
@@ -804,6 +806,9 @@ class CreateView extends GetView<CreateController> {
uppercaseUrl.endsWith(".TORRENT")) { uppercaseUrl.endsWith(".TORRENT")) {
protocol = Protocol.bt; protocol = Protocol.bt;
} }
if (uppercaseUrl.startsWith("ED2K:")) {
protocol = Protocol.ed2k;
}
return protocol; return protocol;
} }
@@ -1007,6 +1012,9 @@ class CreateView extends GetView<CreateController> {
..trackers = Util.textToLines(_btTrackerController.text); ..trackers = Util.textToLines(_btTrackerController.text);
} }
break; break;
case Protocol.ed2k:
case null:
break;
} }
return reqExtra; return reqExtra;
} }
@@ -1,38 +1,278 @@
import 'package:get/get.dart'; import 'dart:async';
import 'package:gopeed/api/api.dart'; import 'dart:collection';
import 'package:get/get.dart';
import '../../../../api/api.dart';
import '../../../../api/gopeed_site_api.dart';
import '../../../../api/model/extension.dart'; import '../../../../api/model/extension.dart';
import '../../../../api/model/install_extension.dart';
import '../../../../api/model/store_extension.dart';
import '../../../../api/model/switch_extension.dart';
enum ExtensionListFilter {
market,
installed,
}
class ExtensionListItem {
final Extension? installed;
final StoreExtension? store;
const ExtensionListItem({this.installed, this.store});
bool get isInstalled => installed != null;
String get id => installed?.identity ?? store!.id;
String get title => installed?.title ?? store?.title ?? '';
String get author => installed?.author ?? store?.author ?? '';
String get description => installed?.description ?? store?.description ?? '';
String get version => installed?.version ?? store?.version ?? '0.0.0';
String? get homepage {
final installedHomepage = installed?.homepage;
if (installedHomepage != null && installedHomepage.isNotEmpty) {
return installedHomepage;
}
return store?.homepage;
}
String? get repoUrl {
final installedRepo = installed?.repository?.url;
if (installedRepo != null && installedRepo.isNotEmpty) {
return installedRepo;
}
return store?.repoUrl;
}
int get stars => store?.stars ?? 0;
int get installCount => store?.installCount ?? 0;
String? get icon => store?.icon;
}
class ExtensionController extends GetxController { class ExtensionController extends GetxController {
final extensions = <Extension>[].obs; static const manualInstallBusyKey = '__manual_install__';
final installedExtensions = <Extension>[].obs;
final updateFlags = <String, String>{}.obs; final updateFlags = <String, String>{}.obs;
final storeExtensions = <StoreExtension>[].obs;
final storePagination = Rxn<StorePagination>();
final storeQuery = ''.obs;
final storeSort = StoreExtensionSort.stars.obs;
final listFilter = ExtensionListFilter.market.obs;
final loadingInstalled = false.obs;
final loadingStore = false.obs;
final loadingMoreStore = false.obs;
final busyExtensionIds = <String>{}.obs;
final showInstallTools = false.obs;
final devMode = false.obs; final devMode = false.obs;
var _devModeCount = 0; var _devModeCount = 0;
/// Flag to prevent handling pending install multiple times
bool pendingInstallHandled = false; bool pendingInstallHandled = false;
@override UnmodifiableMapView<String, Extension> get installedMap =>
void onInit() async { UnmodifiableMapView(
super.onInit(); {for (final ext in installedExtensions) ext.identity: ext});
await load();
checkUpdate(); UnmodifiableMapView<String, StoreExtension> get storeMap =>
UnmodifiableMapView({for (final ext in storeExtensions) ext.id: ext});
List<ExtensionListItem> get displayItems {
if (listFilter.value == ExtensionListFilter.installed) {
return installedExtensions
.map((ext) =>
ExtensionListItem(installed: ext, store: storeMap[ext.identity]))
.toList();
} }
Future<void> load() async { return storeExtensions
extensions.value = await getExtensions(); .map((ext) =>
ExtensionListItem(installed: installedMap[ext.id], store: ext))
.toList();
}
@override
Future<void> onInit() async {
super.onInit();
await loadInitialData();
}
Future<void> loadInitialData() async {
await Future.wait([loadInstalled(refreshUpdates: true), refreshStore()]);
}
Future<void> loadInstalled({bool refreshUpdates = false}) async {
loadingInstalled.value = true;
try {
installedExtensions.value = await getExtensions();
if (refreshUpdates) {
await checkUpdate();
}
} finally {
loadingInstalled.value = false;
}
} }
Future<void> checkUpdate() async { Future<void> checkUpdate() async {
for (final ext in extensions) { final nextFlags = <String, String>{};
for (final ext in installedExtensions) {
try {
final resp = await upgradeCheckExtension(ext.identity); final resp = await upgradeCheckExtension(ext.identity);
if (resp.newVersion.isNotEmpty) { if (resp.newVersion.isNotEmpty) {
updateFlags[ext.identity] = resp.newVersion; nextFlags[ext.identity] = resp.newVersion;
} }
} catch (_) {
// Ignore single extension check failures to avoid breaking the whole list.
}
}
updateFlags.assignAll(nextFlags);
}
Future<void> refreshStore() async {
loadingStore.value = true;
try {
final page = await GopeedSiteApi.instance.getExtensions(
page: 1,
limit: 20,
sort: storeSort.value,
query: storeQuery.value,
);
storeExtensions.assignAll(page.data);
storePagination.value = page.pagination;
} finally {
loadingStore.value = false;
} }
} }
// Try to open dev mode when install button is clicked 5 times in 2 seconds Future<void> loadMoreStore() async {
final pagination = storePagination.value;
if (pagination == null || !pagination.hasNext || loadingMoreStore.value) {
return;
}
loadingMoreStore.value = true;
try {
final page = await GopeedSiteApi.instance.getExtensions(
page: pagination.page + 1,
limit: pagination.limit,
sort: storeSort.value,
query: storeQuery.value,
);
storeExtensions.addAll(page.data);
storePagination.value = page.pagination;
} finally {
loadingMoreStore.value = false;
}
}
Future<void> searchStore(String query) async {
storeQuery.value = query.trim();
await refreshStore();
}
Future<void> changeSort(StoreExtensionSort sort) async {
if (storeSort.value == sort) {
await refreshStore();
return;
}
storeSort.value = sort;
await refreshStore();
}
void changeFilter(ExtensionListFilter filter) {
listFilter.value = filter;
}
void toggleInstallTools() {
showInstallTools.value = !showInstallTools.value;
}
Extension? findInstalled(StoreExtension extension) {
return installedMap[extension.id];
}
bool canUpdateFromStore(StoreExtension extension) {
final installed = findInstalled(extension);
if (installed == null) return false;
return _compareVersion(extension.version, installed.version) > 0;
}
bool canUpdateItem(ExtensionListItem item) {
if (item.installed == null) return false;
if (listFilter.value == ExtensionListFilter.market && item.store != null) {
return canUpdateFromStore(item.store!);
}
return updateFlags.containsKey(item.installed!.identity);
}
Future<void> installFromStore(StoreExtension extension) async {
await _runBusy(extension.id, () async {
final installUrl = (extension.directory ?? '').trim().isEmpty
? extension.repoUrl
: '${extension.repoUrl}#${extension.directory!.trim()}';
final installedId =
await installExtension(InstallExtension(url: installUrl));
final statsId = installedId.isNotEmpty ? installedId : extension.id;
await loadInstalled(refreshUpdates: false);
_backgroundCheckUpdate();
_bumpStoreInstallCount(statsId);
_reportInstallSafe(statsId);
});
}
Future<void> installFromUrl(String url,
{bool devInstall = false, String? statsId}) async {
await _runBusy(manualInstallBusyKey, () async {
final installedId = await installExtension(
InstallExtension(devMode: devInstall, url: url));
await loadInstalled(refreshUpdates: false);
_backgroundCheckUpdate();
if (installedId.isNotEmpty) {
_bumpStoreInstallCount(installedId);
_reportInstallSafe(installedId);
} else if (statsId != null && statsId.isNotEmpty) {
_bumpStoreInstallCount(statsId);
_reportInstallSafe(statsId);
}
});
}
Future<void> toggleExtension(Extension extension, bool enabled) async {
await _runBusy(extension.identity, () async {
await switchExtension(
extension.identity, SwitchExtension(status: enabled));
await loadInstalled(refreshUpdates: false);
});
}
Future<void> removeExtension(Extension extension) async {
await _runBusy(extension.identity, () async {
await deleteExtension(extension.identity);
await loadInstalled(refreshUpdates: false);
updateFlags.remove(extension.identity);
});
}
Future<void> upgradeExtension(Extension extension) async {
await _runBusy(extension.identity, () async {
await updateExtension(extension.identity);
await loadInstalled(refreshUpdates: false);
_backgroundCheckUpdate();
_bumpStoreInstallCount(extension.identity);
_reportInstallSafe(extension.identity);
});
}
void tryOpenDevMode() { void tryOpenDevMode() {
if (_devModeCount == 0) { if (_devModeCount == 0) {
Future.delayed(const Duration(seconds: 2), () { Future.delayed(const Duration(seconds: 2), () {
@@ -46,4 +286,76 @@ class ExtensionController extends GetxController {
devMode.value = true; devMode.value = true;
} }
} }
static int _compareVersion(String a, String b) {
final aNums = _toVersionNumbers(a);
final bNums = _toVersionNumbers(b);
final maxLen = aNums.length > bNums.length ? aNums.length : bNums.length;
for (var i = 0; i < maxLen; i++) {
final left = i < aNums.length ? aNums[i] : 0;
final right = i < bNums.length ? bNums[i] : 0;
if (left > right) return 1;
if (left < right) return -1;
}
return 0;
}
static List<int> _toVersionNumbers(String version) {
return version
.split(RegExp(r'[^0-9]+'))
.where((part) => part.isNotEmpty)
.map((part) => int.tryParse(part) ?? 0)
.toList();
}
Future<void> _runBusy(String id, Future<void> Function() action) async {
if (busyExtensionIds.contains(id)) return;
busyExtensionIds.add(id);
try {
await action();
} finally {
busyExtensionIds.remove(id);
}
}
void _backgroundCheckUpdate() {
unawaited(checkUpdate());
}
void _reportInstallSafe(String id) {
unawaited(() async {
try {
await GopeedSiteApi.instance.reportExtensionInstall(id);
} catch (_) {
// Ignore stats reporting failures.
}
}());
}
void _bumpStoreInstallCount(String id) {
final index = storeExtensions.indexWhere((e) => e.id == id);
if (index < 0) return;
final ext = storeExtensions[index];
storeExtensions[index] = StoreExtension(
id: ext.id,
repoFullName: ext.repoFullName,
repoUrl: ext.repoUrl,
directory: ext.directory,
commitSha: ext.commitSha,
name: ext.name,
author: ext.author,
title: ext.title,
description: ext.description,
icon: ext.icon,
version: ext.version,
homepage: ext.homepage,
readme: ext.readme,
installCount: ext.installCount + 1,
stars: ext.stars,
topics: ext.topics,
createdAt: ext.createdAt,
updatedAt: ext.updatedAt,
);
}
} }
@@ -0,0 +1,273 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:path/path.dart' as path;
import 'package:url_launcher/url_launcher.dart';
import '../../../../api/api.dart';
import '../../../../database/database.dart';
import '../../../../util/util.dart';
import '../controllers/extension_controller.dart';
class ExtensionCard extends StatelessWidget {
const ExtensionCard({
super.key,
required this.item,
required this.busy,
required this.canUpdate,
this.onTap,
this.onToggle,
this.onOpenSetting,
this.onUpdate,
this.onDelete,
this.onInstall,
});
final ExtensionListItem item;
final bool busy;
final bool canUpdate;
final VoidCallback? onTap;
final Future<void> Function(bool value)? onToggle;
final VoidCallback? onOpenSetting;
final VoidCallback? onUpdate;
final VoidCallback? onDelete;
final VoidCallback? onInstall;
@override
Widget build(BuildContext context) {
final installed = item.installed;
final store = item.store;
final installedFlag = item.isInstalled;
final content = Stack(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.2),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCardIcon(item),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 2),
Text(
'${item.author} · v${item.version}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
if (installed != null) ...[
Transform.scale(
scale: 0.82,
child: Switch(
value: !installed.disabled,
onChanged: busy || onToggle == null
? null
: (value) async {
await onToggle!(value);
},
),
),
],
],
),
const SizedBox(height: 8),
Text(
item.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const Spacer(),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Row(
children: [
if (store != null) ...[
_metricItem(
context, Icons.star_rounded, item.stars.toString()),
const SizedBox(width: 10),
_metricItem(context, Icons.download_outlined,
item.installCount.toString()),
],
],
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (installed != null && canUpdate)
IconButton.filledTonal(
tooltip: 'newVersionUpdate'.tr,
onPressed: busy ? null : onUpdate,
icon: busy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh_rounded),
),
if (!installedFlag && store != null)
IconButton.filledTonal(
tooltip: 'extensionInstall'.tr,
onPressed: busy ? null : onInstall,
icon: busy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download),
),
if ((item.homepage ?? '').isNotEmpty)
IconButton(
tooltip: 'homepage'.tr,
onPressed: () => launchUrl(Uri.parse(item.homepage!)),
icon: const Icon(Icons.home_outlined),
),
if ((item.repoUrl ?? '').isNotEmpty)
IconButton(
tooltip: 'GitHub',
onPressed: () => launchUrl(Uri.parse(item.repoUrl!)),
icon: const Icon(Icons.code),
),
if (installed != null &&
installed.settings?.isNotEmpty == true)
IconButton(
tooltip: 'setting'.tr,
onPressed: busy ? null : onOpenSetting,
icon: const Icon(Icons.settings),
),
if (installed != null)
IconButton(
tooltip: 'delete'.tr,
onPressed: busy ? null : onDelete,
icon: const Icon(Icons.delete_outline),
),
],
),
],
),
],
),
),
if (installedFlag && canUpdate)
Positioned(
top: 10,
right: 10,
child: _updateDot(),
),
],
);
if (onTap == null) return content;
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: content,
);
}
Widget _buildCardIcon(ExtensionListItem item) {
final storeIcon = item.icon;
if (storeIcon != null && storeIcon.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
storeIcon,
width: 42,
height: 42,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Image.asset(
'assets/extension/default_icon.png',
width: 42,
height: 42),
),
);
}
final extension = item.installed;
if (extension == null) {
return Image.asset('assets/extension/default_icon.png',
width: 42, height: 42);
}
final image = extension.icon.isEmpty
? Image.asset('assets/extension/default_icon.png',
width: 42, height: 42)
: Util.isWeb()
? Image.network(
join('/fs/extensions/${extension.identity}/${extension.icon}'),
width: 42,
height: 42,
headers: {
'Authorization': 'Bearer ${Database.instance.getWebToken()}'
},
errorBuilder: (_, __, ___) => Image.asset(
'assets/extension/default_icon.png',
width: 42,
height: 42),
)
: Image.file(
extension.devMode
? File(path.join(extension.devPath, extension.icon))
: File(path.join(Util.getStorageDir(), 'extensions',
extension.identity, extension.icon)),
width: 42,
height: 42,
errorBuilder: (_, __, ___) => Image.asset(
'assets/extension/default_icon.png',
width: 42,
height: 42),
);
return ClipRRect(borderRadius: BorderRadius.circular(8), child: image);
}
Widget _metricItem(BuildContext context, IconData icon, String text) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: Theme.of(context).hintColor),
const SizedBox(width: 4),
Text(text, style: Theme.of(context).textTheme.bodyMedium),
],
);
}
Widget _updateDot() {
return Container(
width: 9,
height: 9,
decoration: const BoxDecoration(
color: Colors.redAccent,
shape: BoxShape.circle,
),
);
}
}
@@ -0,0 +1,324 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:get/get.dart';
import 'package:path/path.dart' as path;
import 'package:url_launcher/url_launcher.dart';
import '../../../../api/model/extension.dart';
import '../../../../api/model/store_extension.dart';
import '../../../../util/message.dart';
import '../../../../util/util.dart';
import '../controllers/extension_controller.dart';
class ExtensionDetailDrawer extends GetView<ExtensionController> {
const ExtensionDetailDrawer({
super.key,
required this.extension,
required this.onClose,
this.installed,
});
final StoreExtension extension;
final Extension? installed;
final VoidCallback onClose;
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 12, 10),
child: Row(
children: [
Expanded(
child: Text(
extension.title,
style: Theme.of(context).textTheme.titleLarge,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
onPressed: onClose,
icon: const Icon(Icons.close),
)
],
),
),
const Divider(height: 1),
Expanded(
child: Obx(() {
final localInstalled =
installed ?? controller.findInstalled(extension);
final canUpdate = controller.canUpdateFromStore(extension);
final busy =
controller.busyExtensionIds.contains(extension.id) ||
(localInstalled != null &&
controller.busyExtensionIds
.contains(localInstalled.identity));
return ListView(
padding: const EdgeInsets.all(16),
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildIcon(),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${extension.author} • v${extension.version}'),
const SizedBox(height: 6),
Text(
extension.description,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (localInstalled == null)
ElevatedButton.icon(
onPressed: busy
? null
: () async {
try {
await controller
.installFromStore(extension);
showMessage('tip'.tr,
'extensionInstallSuccess'.tr);
} catch (e) {
showErrorMessage(e);
}
},
icon: busy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Icon(Icons.download),
label: Text('extensionInstall'.tr),
),
if (localInstalled != null && canUpdate)
ElevatedButton.icon(
onPressed: busy
? null
: () async {
try {
await controller
.upgradeExtension(localInstalled);
showMessage('tip'.tr,
'extensionUpdateSuccess'.tr);
} catch (e) {
showErrorMessage(e);
}
},
icon: busy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2),
)
: const Icon(Icons.refresh_rounded),
label: Text('newVersionUpdate'.tr),
),
if ((extension.homepage ?? '').isNotEmpty)
OutlinedButton.icon(
onPressed: () =>
launchUrl(Uri.parse(extension.homepage!)),
icon: const Icon(Icons.home_outlined),
label: Text('homepage'.tr),
),
OutlinedButton.icon(
onPressed: () =>
launchUrl(Uri.parse(extension.repoUrl)),
icon: const Icon(Icons.code),
label: const Text('GitHub'),
),
],
),
const SizedBox(height: 14),
const Divider(height: 1),
const SizedBox(height: 12),
_buildReadme(context, localInstalled),
],
);
}),
),
],
),
),
);
}
Widget _buildReadme(BuildContext context, Extension? installed) {
return FutureBuilder<_ReadmeInfo>(
future: _loadReadme(installed),
builder: (context, snapshot) {
final info = snapshot.data;
final markdown = info?.content ?? extension.readme ?? '';
if (markdown.trim().isEmpty) {
return Text('No README',
style: Theme.of(context).textTheme.bodyMedium);
}
return MarkdownBody(
data: markdown,
selectable: true,
onTapLink: (text, href, title) {
if (href == null || href.isEmpty) return;
final resolved = _resolvePath(href, info, forImage: false);
if (resolved == null) return;
launchUrl(Uri.parse(resolved),
mode: LaunchMode.externalApplication);
},
imageBuilder: (uri, title, alt) {
final resolved = _resolvePath(uri.toString(), info, forImage: true);
if (resolved == null) return const SizedBox.shrink();
if (resolved.startsWith('file://')) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Image.file(
File(Uri.parse(resolved).toFilePath()),
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Image.network(
resolved,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
);
},
);
},
);
}
Future<_ReadmeInfo> _loadReadme(Extension? installed) async {
if (installed == null) {
return _ReadmeInfo(
content: extension.readme ?? '',
mode: _ReadmeMode.remote,
localReadmePath: null,
);
}
final rootDir = installed.devMode
? installed.devPath
: path.join(Util.getStorageDir(), 'extensions', installed.identity);
final candidates = [
path.join(rootDir, 'README.md'),
path.join(rootDir, 'readme.md'),
path.join(rootDir, 'README.MD'),
];
for (final filePath in candidates) {
final file = File(filePath);
if (await file.exists()) {
return _ReadmeInfo(
content: await file.readAsString(),
mode: _ReadmeMode.local,
localReadmePath: filePath,
);
}
}
return _ReadmeInfo(
content: extension.readme ?? '',
mode: _ReadmeMode.remote,
localReadmePath: null,
);
}
String? _resolvePath(String raw, _ReadmeInfo? info,
{required bool forImage}) {
final value = raw.trim();
if (value.isEmpty) return null;
final uri = Uri.tryParse(value);
if (uri != null && uri.hasScheme) {
return uri.toString();
}
if (info?.mode == _ReadmeMode.local &&
info?.localReadmePath != null &&
forImage) {
final readmeDir = path.dirname(info!.localReadmePath!);
final clean = value.split('#').first;
final absolute = path.normalize(path.join(readmeDir, clean));
return Uri.file(absolute).toString();
}
final ref =
extension.commitSha?.isNotEmpty == true ? extension.commitSha! : 'HEAD';
final dir = (extension.directory ?? '').trim();
final baseSegments = [
if (dir.isNotEmpty) ...dir.split('/').where((e) => e.isNotEmpty),
'',
];
final base = forImage
? Uri.https('raw.githubusercontent.com',
'/${extension.repoFullName}/$ref/${baseSegments.join('/')}')
: Uri.https('github.com',
'/${extension.repoFullName}/blob/$ref/${baseSegments.join('/')}');
return base.resolve(value).toString();
}
Widget _buildIcon() {
if ((extension.icon ?? '').isEmpty) {
return Image.asset('assets/extension/default_icon.png',
width: 56, height: 56);
}
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
extension.icon!,
width: 56,
height: 56,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) {
return Image.asset('assets/extension/default_icon.png',
width: 56, height: 56);
},
),
);
}
}
enum _ReadmeMode {
remote,
local,
}
class _ReadmeInfo {
final String content;
final _ReadmeMode mode;
final String? localReadmePath;
_ReadmeInfo({
required this.content,
required this.mode,
required this.localReadmePath,
});
}
File diff suppressed because it is too large. Load diff
@@ -498,6 +498,29 @@ class SettingView extends GetView<SettingController> {
); );
}); });
final buildDesktopNotification = !Util.isDesktop()
? () => null
: _buildConfigItem('desktopNotification', () {
return appController
.downloaderConfig.value.extra.desktopNotification
? 'on'.tr
: 'off'.tr;
}, (Key key) {
return Container(
alignment: Alignment.centerLeft,
child: Switch(
value: appController
.downloaderConfig.value.extra.desktopNotification,
onChanged: (bool value) async {
appController.downloaderConfig.update((val) {
val!.extra.desktopNotification = value;
});
await debounceSave();
},
),
);
});
// http config items start // http config items start
final httpConfig = downloaderCfg.value.protocolConfig.http; final httpConfig = downloaderCfg.value.protocolConfig.http;
final buildHttpUa = final buildHttpUa =
@@ -766,6 +789,138 @@ class SettingView extends GetView<SettingController> {
); );
}); });
// ed2k config items start
final ed2kConfig = downloaderCfg.value.protocolConfig.ed2k;
List<String> parseEd2kEntries(String value) {
return value
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
}
String summarizeEd2kEntries(String value) {
final entries = parseEd2kEntries(value);
if (entries.isEmpty) {
return 'notSet'.tr;
}
return 'items'.trParams({'count': entries.length.toString()});
}
String formatEd2kMultilineValue(String value) {
return parseEd2kEntries(value).join('\r\n');
}
final buildEd2kListenPort = _buildConfigItem(
'ed2kTcpPort', () => ed2kConfig.listenPort.toString(), (Key key) {
final controller =
TextEditingController(text: ed2kConfig.listenPort.toString());
controller.addListener(() async {
if (controller.text.isNotEmpty &&
controller.text != ed2kConfig.listenPort.toString()) {
ed2kConfig.listenPort = int.parse(controller.text);
await debounceSave();
}
});
return TextField(
key: key,
focusNode: FocusNode(),
controller: controller,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
NumericalRangeFormatter(min: 0, max: 65535),
],
);
});
final buildEd2kUdpPort = _buildConfigItem(
'ed2kUdpPort', () => ed2kConfig.udpPort.toString(), (Key key) {
final controller =
TextEditingController(text: ed2kConfig.udpPort.toString());
controller.addListener(() async {
if (controller.text.isNotEmpty &&
controller.text != ed2kConfig.udpPort.toString()) {
ed2kConfig.udpPort = int.parse(controller.text);
await debounceSave();
}
});
return TextField(
key: key,
focusNode: FocusNode(),
controller: controller,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
NumericalRangeFormatter(min: 0, max: 65535),
],
);
});
final buildEd2kServerAddr = _buildConfigItem(
'ed2kServerList', () => summarizeEd2kEntries(ed2kConfig.serverAddr),
(Key key) {
final controller = TextEditingController(
text: formatEd2kMultilineValue(ed2kConfig.serverAddr));
return TextField(
key: key,
focusNode: FocusNode(),
controller: controller,
keyboardType: TextInputType.multiline,
maxLines: 5,
decoration: InputDecoration(
hintText: 'ed2kServersHint'.tr,
helperText: 'ed2kOnePerLine'.tr,
),
onChanged: (value) async {
ed2kConfig.serverAddr = Util.textToLines(value).join(',');
await debounceSave();
},
);
});
final buildEd2kServerMet = _buildConfigItem(
'ed2kServerMet', () => summarizeEd2kEntries(ed2kConfig.serverMet),
(Key key) {
final controller = TextEditingController(
text: formatEd2kMultilineValue(ed2kConfig.serverMet));
return TextField(
key: key,
focusNode: FocusNode(),
controller: controller,
keyboardType: TextInputType.multiline,
maxLines: 4,
decoration: InputDecoration(
hintText: 'ed2kServerMetHint'.tr,
helperText: 'ed2kOnePerLine'.tr,
),
onChanged: (value) async {
ed2kConfig.serverMet = Util.textToLines(value).join(',');
await debounceSave();
},
);
});
final buildEd2kNodesDat = _buildConfigItem(
'ed2kNodesDat', () => summarizeEd2kEntries(ed2kConfig.nodesDat),
(Key key) {
final controller = TextEditingController(
text: formatEd2kMultilineValue(ed2kConfig.nodesDat));
return TextField(
key: key,
focusNode: FocusNode(),
controller: controller,
keyboardType: TextInputType.multiline,
maxLines: 4,
decoration: InputDecoration(
hintText: 'ed2kNodesDatHint'.tr,
helperText: 'ed2kOnePerLine'.tr,
),
onChanged: (value) async {
ed2kConfig.nodesDat = Util.textToLines(value).join(',');
await debounceSave();
},
);
});
// ui config items start // ui config items start
final buildTheme = _buildConfigItem( final buildTheme = _buildConfigItem(
'theme', 'theme',
@@ -1592,6 +1747,7 @@ class SettingView extends GetView<SettingController> {
buildBrowserExtension(), buildBrowserExtension(),
buildAutoStartup(), buildAutoStartup(),
buildMenubarMode(), buildMenubarMode(),
buildDesktopNotification(),
]), ]),
)), )),
Text('archives'.tr), Text('archives'.tr),
@@ -1626,6 +1782,17 @@ class SettingView extends GetView<SettingController> {
buildBtDefaultClientConfig(), buildBtDefaultClientConfig(),
]), ]),
)), )),
Text('ed2k'.tr),
Card(
child: Column(
children: _addDivider([
buildEd2kListenPort(),
buildEd2kUdpPort(),
buildEd2kServerAddr(),
buildEd2kServerMet(),
buildEd2kNodesDat(),
]),
)),
Text('ui'.tr), Text('ui'.tr),
Card( Card(
child: Column( child: Column(
@@ -0,0 +1,146 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:get/get.dart';
import 'package:path_provider/path_provider.dart';
import '../../api/api.dart';
import '../../api/model/task.dart';
import '../../util/util.dart';
import '../modules/app/controllers/app_controller.dart';
class NotificationService extends GetxService {
Timer? _timer;
final Map<String, Status> _previousStatus = {};
final AppController appController = Get.find<AppController>();
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
@override
void onInit() {
super.onInit();
_initNotifications();
_startPolling();
}
Future<void> _initNotifications() async {
const DarwinInitializationSettings initializationSettingsDarwin =
DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false);
final LinuxInitializationSettings initializationSettingsLinux =
LinuxInitializationSettings(
defaultActionName: 'Open notification',
defaultIcon: AssetsLinuxIcon('assets/icon/icon.png'),
);
String? windowsIconPath;
try {
if (Util.isWindows()) {
final byteData = await rootBundle.load('assets/icon/icon.ico');
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/notification_icon.ico');
await file.writeAsBytes(byteData.buffer
.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes));
windowsIconPath = file.path;
}
} catch (e) {
// Ignore
}
final WindowsInitializationSettings initializationSettingsWindows =
WindowsInitializationSettings(
appName: 'Gopeed',
appUserModelId: 'com.gopeed.gopeed',
guid: '3c1bf3f4-3d91-4eaa-a33f-8705e71cf1ce', // unique guid
iconPath: windowsIconPath,
);
final InitializationSettings initializationSettings =
InitializationSettings(
macOS: initializationSettingsDarwin,
linux: initializationSettingsLinux,
windows: initializationSettingsWindows,
);
await flutterLocalNotificationsPlugin.initialize(
settings: initializationSettings,
);
}
@override
void onClose() {
_timer?.cancel();
super.onClose();
}
void _startPolling() {
_timer = Timer.periodic(const Duration(seconds: 2), (timer) async {
try {
final config = appController.downloaderConfig.value;
if (!config.extra.desktopNotification) {
return;
}
final tasks = await getTasks([
Status.ready,
Status.running,
Status.pause,
Status.wait,
Status.error,
Status.done,
]);
for (var task in tasks) {
final prevStatus = _previousStatus[task.id];
final currentStatus = task.status;
if (prevStatus != null && prevStatus != currentStatus) {
if (currentStatus == Status.done) {
_showNotification(
title: 'notificationTaskDone'.tr,
body: task.name,
);
} else if (currentStatus == Status.error) {
_showNotification(
title: 'notificationTaskError'.tr,
body: task.name,
);
}
}
_previousStatus[task.id] = currentStatus;
}
// Clean up deleted tasks from map
final currentTaskIds = tasks.map((t) => t.id).toSet();
_previousStatus
.removeWhere((id, status) => !currentTaskIds.contains(id));
} catch (e) {
// Ignored
}
});
}
int _notificationId = 0;
Future<void> _showNotification(
{required String title, required String body}) async {
const NotificationDetails notificationDetails = NotificationDetails(
macOS: DarwinNotificationDetails(),
linux: LinuxNotificationDetails(),
windows: WindowsNotificationDetails(),
);
await flutterLocalNotificationsPlugin.show(
id: _notificationId++,
title: title,
body: body,
notificationDetails: notificationDetails,
);
}
}
+245
View File
@@ -0,0 +1,245 @@
const caES = {
'ca_ES': {
'label': 'Català',
'error': 'Error',
'tip': 'Consell',
'confirm': 'Confirma',
'confirmDelete': 'Esteu segur que voleu suprimir-lo?',
'cancel': 'Cancel·la',
'on': 'Activat',
'off': 'Desactivat',
'selectAll': 'Selecciona-ho tot',
'select': 'Selecciona',
'task': 'Tasques',
'downloading': 's\'està baixant',
'downloaded': 's\'ha baixat',
'setting': 'Configuració',
'donate': 'Donació',
'exit': 'Surt',
'create': 'Crea una tasca',
'directDownload': 'Baixada directa',
'advancedOptions': 'Opcions avançades',
'followSettings': 'Segueix la configuració',
'downloadLink': 'Enllaç de baixada',
'downloadLinkValid': 'Introduïu l\'enllaç de baixada',
'downloadLinkHit': 'Introduïu l\'enllaç de baixada, un per línia@append',
'downloadLinkHitDesktop': 'o arrossegueu el fitxer torrent aquí',
'download': 'Baixa',
'noFileSelected': 'Seleccioneu almenys un fitxer per continuar.',
'noStoragePermission': 'Cal permís d\'emmagatzematge',
'selectFile': 'Seleccioneu el fitxer',
'rename': 'Canvia el nom',
'basic': 'Bàsic',
'advanced': 'Avançat',
'general': 'General',
'downloadDir': 'Carpeta de baixades',
'downloadDirValid': 'Seleccioneu la carpeta de baixades',
'connections': 'Connexions',
'useServerCtime':
'Utilitza l\'hora del servidor per a la creació de fitxers',
'maxRunning': 'Màxim de tasques en execució',
'defaultDirectDownload': 'Activa la baixada directa per defecte',
'autoStartTasks':
'Inicia automàticament les tasques incompletes a l\'inici',
'autoTorrentEnable':
'Crea automàticament tasques BT a partir de fitxers .torrent',
'autoTorrentDeleteAfterDownload':
'Suprimeix el fitxer .torrent després de crear la tasca BT',
'autoDeleteMissingFileTasks':
'Suprimeix automàticament les tasques dels fitxers que falten',
'items': '@count elements',
'subscribeTracker': 'Subscripció al rastrejador',
'subscribeFail':
'La subscripció ha fallat. Comproveu la xarxa o torneu-ho a provar més tard',
'update': 'Actualitza',
'updateDaily': 'Actualització diària',
'lastUpdate': 'Última actualització: @time',
'addTracker': 'Afegeix un rastrejador',
'addTrackerHit':
'Introduïu la URL del servidor del rastrejador, un per línia',
'ui': 'UI',
'theme': 'Tema',
'themeSystem': 'Sistema',
'themeLight': 'Clar',
'themeDark': 'Fosc',
'locale': 'Idioma',
'notifyWhenNewVersion': 'Notificació d\'actualitzacions',
'analyticsEnabled': 'Puja les estadístiques',
'analyticsEnabledDesc':
'Compartiu dades d\'ús anònimes per ajudar-nos a millorar',
'about': 'Quant a',
'homepage': 'Pàgina d\'inici',
'version': 'Versió',
'protocol': 'Protocol',
'port': 'Port',
'apiToken': 'Token de l\'API',
'notSet': 'Sense congigurar',
'set': 'Configurat',
'portInUse': 'El port [@port] està en ús, canvieu-lo',
'effectAfterRestart': 'Els efectes s\'aplicaran després de reiniciar',
'developer': 'Desenvolupador',
'logDirectory': 'Carpeta dels registres',
'webhook': 'Webhook',
'webhookEnable': 'Activa el Webhook',
'webhookDesc':
'Envia notificacions HTTP POST quan les tasques finalitzin o fallin',
'webhookUrlHint': 'Introduïu la URL del webhook',
'webhookTest': 'Prova',
'webhookTestSuccess': 'La prova del webhook ha estat correcta',
'webhookTestFail': 'La prova del webhook ha fallat',
'script': 'Seqüència d\'ordres',
'scriptEnable': 'Activa la seqüència d\'ordres',
'scriptDesc':
'Executa seqüències d\'ordres personalitzades quan les baixades finalitzin correctament',
'scriptPathHint':
'Introduïu la ruta del fitxer de seqüències d\'ordres (p.ex., /ruta/al/fitxer.sh)',
'urlInvalid': 'Introduïu una URL HTTP o HTTPS vàlida',
'required': 'Aquest camp és obligatori',
'show': 'Mostra',
'continue': 'Contina',
'pause': 'Pausa',
'startAll': 'Inicia-ho tot',
'pauseAll': 'Pausa-ho tot',
'deleteTask': 'Suprimeix @count tasques',
'deleteTaskTip': 'Mantén els fitxers baixats',
'delete': 'Suprimeix',
'add': 'Afegeix',
'edit': 'Edita',
'newVersionTitle': 'Descobriu la nova versió @version',
'newVersionUpdate': 'Actualitza ara',
'newVersionLater': 'Més tard',
'extensions': 'Extensions',
'extensionInstallUrl': 'URL d\'instal·lació',
'extensionInstallSuccess': 'S\'ha instal·lat amb èxit',
'extensionUpdateSuccess': 'S\'ha actualitzat amb èxit',
'extensionDelete': 'Suprimeix l\'extensió',
'extensionAlreadyLatest': 'Ja està actualitzada',
'extensionFind': 'Cerca extensions',
'extensionDevelop': 'Desenvolupa extensions',
'extensionStoreTitle': 'Botiga d\'extensions',
'extensionStoreSection': 'Extensions de la botiga',
'extensionInstalledSection': 'Extensions instal·lades',
'extensionInstalledEmpty': 'Encara no s\'ha instal·lat cap extensió',
'extensionStoreEmpty': 'No s\'han trobat extensions coincidents',
'extensionSearchHint': 'Cerca per nom, títol, descripció o autor',
'extensionSortStars': 'Més estrelles',
'extensionSortInstalls': 'Més instal·lacions',
'extensionSortUpdated': 'Actualitzada recentment',
'extensionSortToggle': 'Commuta la direcció d\'ordenació',
'extensionLoadMore': 'Carrega\'n més',
'extensionNoMore': 'No hi ha més extensions',
'extensionInstalled': 'Instal·lada',
'extensionCanUpdate': 'Actualització disponible',
'extensionInstall': 'Instal·la',
'extensionLoadLocal': 'Instal·la de d\'una carpeta local',
'extensionFilterAll': 'Totes',
'extensionFilterMarket': 'Mercat',
'extensionFilterInstalled': 'Instal·lada',
'extensionManualInstall': 'Instal·lació manual',
'extensionInstallTools': 'Opcions d\'instal·lació',
'history': 'Historial',
'clearHistory': 'Neteja l\'historial',
'noHistoryFound': 'No s\'ha trobat cap historial',
'serviceTitle': 'Servei de baixades',
'serviceText': 'S\'està executant',
'network': 'Xarxa',
'proxy': 'Servidor intermediari',
'noProxy': 'Sense servidor intermediari',
'systemProxy': 'Servidor intermediari del sistema',
'customProxy': 'Servidor intermediari personalitzat',
'server': 'Servidor',
'username': 'Nom d\'usuari',
'password': 'Contrasenya',
'thanks': 'Gràcies',
'thanksDesc':
'Gràcies a tots els col·laboradors que han ajudat a construir i desenvolupar la comunitat del Gopeed!',
'browserExtension': 'Extensió del navegador',
'launchAtStartup': 'Executa a l\'inici',
'runAsMenubarApp': 'Executa com a aplicació de barra de menú',
'runAsMenubarAppDesc':
'Amaga la icona de l\'acoblador i executa només a la barra de menú',
'seedConfig': 'Configuració de les llavors',
'seedKeep': 'Continua sembrant fins que s\'aturi manualment',
'seedRatio': 'Proporció de les llavors',
'seedTime': 'Temps de la llavor (minuts)',
'setAsDefaultBtClient': 'Estableix com a client BT per defecte',
'taskDetail': 'Detall de la tasca',
'taskName': 'Nom de la tasca',
'taskUrl': 'URL de la tasca',
'downloadPath': 'Ruta de la baixada',
'skipVerifyCert': 'Omet la verificació del certificat',
'archives': 'Comprimits',
'autoExtract': 'Extreu els comprimits automàticament',
'archivePassword': 'Contrasenya del comprimit',
'archivePasswordHint':
'Deixeu-lo en blanc si no està protegit amb contrasenya',
'deleteAfterExtract': 'Suprimeix el comprimit després de l\'extracció',
'extracting': 'S\'està extraient',
'extractDone': 'Extracció completada',
'extractError': 'Extracció fallida',
'waitingParts': 'S\'està esperant a les parts',
'name': 'Nom',
'size': 'Mida',
'unknown': 'Desconegut',
'fileSelectedCount': 'Fitxers: ',
'fileSelectedSize': 'Mida: ',
'httpHeader': 'Capçalera',
'httpHeaderName': 'Nomm de la capçalera',
'httpHeaderValue': 'Valor de la capçalera',
'login': 'Inici de sessió',
'username_required': 'Introduïu el vostre nom d\'usuari',
'password_required': 'Introduïu la vostra contrasenya',
'login_success': 'Inici de sessió correcte',
'login_failed':
'No s\'ha pogut iniciar la sessió, comproveu el vostre nom d\'usuari i contrasenya',
'login_failed_network':
'No s\'ha pogut iniciar la sessió, comproveu la vostra connexió de xarxa',
'insertPlaceholder': 'Inseriu un marcador de posició',
'placeholderYear': 'Any actual',
'placeholderMonth': 'Mes actual (01-12)',
'placeholderDay': 'Dia actual (01-31)',
'placeholderDate': 'Data completa (AAAA-MM-DD)',
'example': 'p.ex. @value',
'downloadCategories': 'Categories de baixades',
'categoryMusic': 'Música',
'categoryVideo': 'Vídeo',
'categoryDocument': 'Document',
'categoryProgram': 'Programa',
'categoryName': 'Nom de la categoria',
'categoryPath': 'Ruta de la categoria',
'builtInCategory': 'La categoria integrada no es pot suprimir',
'selectCategory': 'Seleccioneu una categoria',
'githubMirror': 'Rèplica del GitHub',
'githubMirrorEnable': 'Activa la rèplica del GitHub',
'githubMirrorDesc':
'Utilitzeu rèpliques per accelerar les baixades de contingut del GitHub',
'githubMirrorType': 'Tipus de rèplica',
'githubMirrorUrl': 'URL de la rèplica',
'githubMirrorUrlHint': 'Introduïu la URL de la rèplica',
'updateUrl': 'Actualitza la URL',
'updateUrlManual': 'Actualització manual',
'updateUrlListen': 'Escolta l\'actualització',
'updateUrlListeningTip': 'S\'està esperant l\'actualització de l\'URL',
'updateUrlCancelListen': 'Cancel·la l\'escolta',
'updateUrlDialogHint': 'Introduïu una nova URL de baixada',
'pendingUpdateFound': 'S\'ha trobat una tasca d\'actualització pendent',
'pendingUpdateConfirm':
'La tasca "@name" està esperant l\'actualització de la URL. Voleu actualitzar-la amb la URL nova?',
'pendingUpdateYes': 'Actualitza la tasca',
'pendingUpdateNo': 'Crea una tasca nova',
'ed2k': 'ED2K',
'ed2kTcpPort': 'Port d\'escolta TCP',
'ed2kUdpPort': 'Port d\'escolta UDP',
'ed2kServerList': 'Adreces del servidor',
'ed2kServerMet': 'Fonts del Server.met',
'ed2kNodesDat': 'Fonts del nodes.dat',
'ed2kAutoPort': 'Assignació automàtica',
'ed2kOnePerLine': 'Una entrada per línia',
'ed2kServersHint': 'amfitrió:port, un per línia',
'ed2kServerMetHint': 'URL del Server.met o enllaç del llistat de servidors ed2k, un per línia',
'ed2kNodesDatHint': 'Ruta o URL del nodes.dat, una per línia',
'desktopNotification': 'Notificacions d\'escriptori',
'notificationTaskDone': 'Tasca completada',
'notificationTaskError': 'Error de tasca',
},
};
+14
View File
@@ -164,5 +164,19 @@ const deDE = {
'categoryPath': 'Kategoriepfad', 'categoryPath': 'Kategoriepfad',
'builtInCategory': 'Integrierte Kategorie kann nicht gelöscht werden', 'builtInCategory': 'Integrierte Kategorie kann nicht gelöscht werden',
'selectCategory': 'Kategorie auswählen', 'selectCategory': 'Kategorie auswählen',
'ed2k': 'ED2K',
'ed2kTcpPort': 'TCP Listen Port',
'ed2kUdpPort': 'UDP Listen Port',
'ed2kServerList': 'Server Addresses',
'ed2kServerMet': 'Server.met Sources',
'ed2kNodesDat': 'nodes.dat Sources',
'ed2kAutoPort': 'Auto assign',
'ed2kOnePerLine': 'One entry per line',
'ed2kServersHint': 'host:port, one per line',
'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',
'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',
'desktopNotification': 'Desktop-Benachrichtigungen',
'notificationTaskDone': 'Aufgabe abgeschlossen',
'notificationTaskError': 'Aufgabenfehler',
}, },
}; };
+35
View File
@@ -107,6 +107,27 @@ const enUS = {
'extensionAlreadyLatest': 'It\'s already the latest version', 'extensionAlreadyLatest': 'It\'s already the latest version',
'extensionFind': 'Find Extensions', 'extensionFind': 'Find Extensions',
'extensionDevelop': 'Develop Extensions', 'extensionDevelop': 'Develop Extensions',
'extensionStoreTitle': 'Extension Store',
'extensionStoreSection': 'Store Extensions',
'extensionInstalledSection': 'Installed Extensions',
'extensionInstalledEmpty': 'No extensions installed yet',
'extensionStoreEmpty': 'No matching extensions found',
'extensionSearchHint': 'Search by name, title, description or author',
'extensionSortStars': 'Most Stars',
'extensionSortInstalls': 'Most Installs',
'extensionSortUpdated': 'Recently Updated',
'extensionSortToggle': 'Toggle sort direction',
'extensionLoadMore': 'Load More',
'extensionNoMore': 'No more extensions',
'extensionInstalled': 'Installed',
'extensionCanUpdate': 'Update Available',
'extensionInstall': 'Install',
'extensionLoadLocal': 'Install Local Folder',
'extensionFilterAll': 'All',
'extensionFilterMarket': 'Market',
'extensionFilterInstalled': 'Installed',
'extensionManualInstall': 'Manual Install',
'extensionInstallTools': 'Install Options',
'history': 'History', 'history': 'History',
'clearHistory': 'Clear History', 'clearHistory': 'Clear History',
'noHistoryFound': 'No History Found', 'noHistoryFound': 'No History Found',
@@ -193,5 +214,19 @@ const enUS = {
'Task "@name" is waiting for URL update. Do you want to update it with the new URL?', 'Task "@name" is waiting for URL update. Do you want to update it with the new URL?',
'pendingUpdateYes': 'Update Task', 'pendingUpdateYes': 'Update Task',
'pendingUpdateNo': 'Create New Task', 'pendingUpdateNo': 'Create New Task',
'ed2k': 'ED2K',
'ed2kTcpPort': 'TCP Listen Port',
'ed2kUdpPort': 'UDP Listen Port',
'ed2kServerList': 'Server Addresses',
'ed2kServerMet': 'Server.met Sources',
'ed2kNodesDat': 'nodes.dat Sources',
'ed2kAutoPort': 'Auto assign',
'ed2kOnePerLine': 'One entry per line',
'ed2kServersHint': 'host:port, one per line',
'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',
'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',
'desktopNotification': 'Desktop Notifications',
'notificationTaskDone': 'Task completed',
'notificationTaskError': 'Task error',
}, },
}; };
+14
View File
@@ -142,5 +142,19 @@ const esES = {
'categoryPath': 'Ruta de categoría', 'categoryPath': 'Ruta de categoría',
'builtInCategory': 'La categoría incorporada no se puede eliminar', 'builtInCategory': 'La categoría incorporada no se puede eliminar',
'selectCategory': 'Seleccionar categoría', 'selectCategory': 'Seleccionar categoría',
'ed2k': 'ED2K',
'ed2kTcpPort': 'TCP Listen Port',
'ed2kUdpPort': 'UDP Listen Port',
'ed2kServerList': 'Server Addresses',
'ed2kServerMet': 'Server.met Sources',
'ed2kNodesDat': 'nodes.dat Sources',
'ed2kAutoPort': 'Auto assign',
'ed2kOnePerLine': 'One entry per line',
'ed2kServersHint': 'host:port, one per line',
'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',
'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',
'desktopNotification': 'Notificaciones de escritorio',
'notificationTaskDone': 'Tarea completada',
'notificationTaskError': 'Error de tarea',
}, },
}; };
+14
View File
@@ -93,5 +93,19 @@ const faIR = {
'launchAtStartup': 'راه‌اندازی در استارت‌آپ', 'launchAtStartup': 'راه‌اندازی در استارت‌آپ',
'runAsMenubarApp': 'اجرا به عنوان برنامه نوار منو', 'runAsMenubarApp': 'اجرا به عنوان برنامه نوار منو',
'runAsMenubarAppDesc': 'پنهان کردن آیکون Dock و اجرا فقط در نوار منو', 'runAsMenubarAppDesc': 'پنهان کردن آیکون Dock و اجرا فقط در نوار منو',
'ed2k': 'ED2K',
'ed2kTcpPort': 'TCP Listen Port',
'ed2kUdpPort': 'UDP Listen Port',
'ed2kServerList': 'Server Addresses',
'ed2kServerMet': 'Server.met Sources',
'ed2kNodesDat': 'nodes.dat Sources',
'ed2kAutoPort': 'Auto assign',
'ed2kOnePerLine': 'One entry per line',
'ed2kServersHint': 'host:port, one per line',
'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',
'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',
'desktopNotification': 'اعلان‌های دسکتاپ',
'notificationTaskDone': 'وظیفه تکمیل شد',
'notificationTaskError': 'خطای وظیفه',
} }
}; };
+14
View File
@@ -135,5 +135,19 @@ const frFR = {
'categoryPath': 'Chemin de la catégorie', 'categoryPath': 'Chemin de la catégorie',
'builtInCategory': 'La catégorie intégrée ne peut pas être supprimée', 'builtInCategory': 'La catégorie intégrée ne peut pas être supprimée',
'selectCategory': 'Sélectionner une catégorie', 'selectCategory': 'Sélectionner une catégorie',
'ed2k': 'ED2K',
'ed2kTcpPort': 'TCP Listen Port',
'ed2kUdpPort': 'UDP Listen Port',
'ed2kServerList': 'Server Addresses',
'ed2kServerMet': 'Server.met Sources',
'ed2kNodesDat': 'nodes.dat Sources',
'ed2kAutoPort': 'Auto assign',
'ed2kOnePerLine': 'One entry per line',
'ed2kServersHint': 'host:port, one per line',
'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',
'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',
'desktopNotification': 'Notifications de bureau',
'notificationTaskDone': 'Tâche terminée',
'notificationTaskError': 'Erreur de tâche',
}, },
}; };
+14
View File
@@ -156,5 +156,19 @@ const huHU = {
'categoryPath': 'Kategória útvonala', 'categoryPath': 'Kategória útvonala',
'builtInCategory': 'A beépített kategória nem törölhető', 'builtInCategory': 'A beépített kategória nem törölhető',
'selectCategory': 'Kategória kiválasztása', 'selectCategory': 'Kategória kiválasztása',
'ed2k': 'ED2K',
'ed2kTcpPort': 'TCP Listen Port',
'ed2kUdpPort': 'UDP Listen Port',
'ed2kServerList': 'Server Addresses',
'ed2kServerMet': 'Server.met Sources',
'ed2kNodesDat': 'nodes.dat Sources',
'ed2kAutoPort': 'Auto assign',
'ed2kOnePerLine': 'One entry per line',
'ed2kServersHint': 'host:port, one per line',
'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',
'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',
'desktopNotification': 'Asztali értesítések',
'notificationTaskDone': 'Feladat befejezve',
'notificationTaskError': 'Feladat hiba',
}, },
}; };
+14
View File
@@ -142,5 +142,19 @@ const idID = {
'categoryPath': 'Path Kategori', 'categoryPath': 'Path Kategori',
'builtInCategory': 'Kategori bawaan tidak dapat dihapus', 'builtInCategory': 'Kategori bawaan tidak dapat dihapus',
'selectCategory': 'Pilih Kategori', 'selectCategory': 'Pilih Kategori',
'ed2k': 'ED2K',
'ed2kTcpPort': 'TCP Listen Port',
'ed2kUdpPort': 'UDP Listen Port',
'ed2kServerList': 'Server Addresses',
'ed2kServerMet': 'Server.met Sources',
'ed2kNodesDat': 'nodes.dat Sources',
'ed2kAutoPort': 'Auto assign',
'ed2kOnePerLine': 'One entry per line',
'ed2kServersHint': 'host:port, one per line',
'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',
'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',
'desktopNotification': 'Notifikasi Desktop',
'notificationTaskDone': 'Tugas selesai',
'notificationTaskError': 'Tugas error',
}, },
}; };
+14
View File
@@ -130,5 +130,19 @@ const itIT = {
'categoryPath': 'Percorso categoria', 'categoryPath': 'Percorso categoria',
'builtInCategory': 'La categoria predefinita non può essere eliminata', 'builtInCategory': 'La categoria predefinita non può essere eliminata',
'selectCategory': 'Seleziona categoria', 'selectCategory': 'Seleziona categoria',
'ed2k': 'ED2K',
'ed2kTcpPort': 'TCP Listen Port',
'ed2kUdpPort': 'UDP Listen Port',
'ed2kServerList': 'Server Addresses',
'ed2kServerMet': 'Server.met Sources',
'ed2kNodesDat': 'nodes.dat Sources',
'ed2kAutoPort': 'Auto assign',
'ed2kOnePerLine': 'One entry per line',
'ed2kServersHint': 'host:port, one per line',
'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',
'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',
'desktopNotification': 'Notifiche Desktop',
'notificationTaskDone': 'Attività completata',
'notificationTaskError': 'Errore attività',
}, },
}; };
+14
View File
@@ -95,5 +95,19 @@ const jaJP = {
'launchAtStartup': '起動時に起動', 'launchAtStartup': '起動時に起動',
'runAsMenubarApp': 'メニューバーアプリとして実行', 'runAsMenubarApp': 'メニューバーアプリとして実行',
'runAsMenubarAppDesc': 'Dockアイコンを非表示にして、メニューバーのみで実行', 'runAsMenubarAppDesc': 'Dockアイコンを非表示にして、メニューバーのみで実行',
'ed2k': 'ED2K',
'ed2kTcpPort': 'TCP Listen Port',
'ed2kUdpPort': 'UDP Listen Port',
'ed2kServerList': 'Server Addresses',
'ed2kServerMet': 'Server.met Sources',
'ed2kNodesDat': 'nodes.dat Sources',
'ed2kAutoPort': 'Auto assign',
'ed2kOnePerLine': 'One entry per line',
'ed2kServersHint': 'host:port, one per line',
'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line',
'ed2kNodesDatHint': 'nodes.dat path or URL, one per line',
'desktopNotification': 'デスクトップ通知',
'notificationTaskDone': 'タスク完了',
'notificationTaskError': 'タスクエラー',
} }
}; };
Loaded 50 of 64 files, more files were not shown because too many files have changed in this diff. Show more