Compare commits

..
126 Commits
Author SHA1 Message Date
liwei 70c16195ef release: v1.3.2 2023-06-09 22:17:27 +08:00
Li Wei eb9d966855 feat: support start and pause all tasks (#121) 2023-06-09 22:15:59 +08:00
liwei 0e3ca912a7 fix: add mac tray template icon 2023-06-08 14:10:55 +08:00
Li Wei b3e372326f feat: add desktop system tray support (#120) 2023-06-07 21:50:40 +08:00
Li Wei 0100f0131d feat: android deeplink and file selector (#118) 2023-06-06 20:39:07 +08:00
Levi e4c3b449a4 chore: update readme 2023-05-23 18:23:51 +08:00
liwei aaa8cd6406 fix: flutter downgrade to v3.7.12 2023-05-11 18:06:25 +08:00
Li Wei 328422b3b8 feat: flutter upgrade to v3.10.0 (#113) 2023-05-11 11:42:55 +08:00
Li Wei 17cce89ef1 release: v1.3.1 2023-05-11 09:19:56 +08:00
Li Wei d9c5ca339f feat: support max running tasks config (#112) 2023-05-10 18:00:58 +08:00
Li Wei 1e08ecd8db fix: file explorer and support select file (#111) 2023-05-04 14:32:37 +08:00
Li Wei 34bcbd9571 fix: gopeed cli negative WaitGroup counter error (#110) 2023-05-04 10:05:32 +08:00
liwei 09bdb5584a ci: fix mac build 2023-04-01 21:29:57 +08:00
liwei 36f5c9569e ci: debug 2023-04-01 21:21:09 +08:00
liwei 3d9edc5133 ci: debug 2023-04-01 21:10:41 +08:00
liwei ed08c05532 ci: fix 2023-04-01 20:58:33 +08:00
liwei 9bd8befcb8 fix: mac universal binary build 2023-04-01 20:49:51 +08:00
liwei 7ba87e48fc fix: mac build 2023-04-01 20:31:55 +08:00
Li Wei af5f739630 ci: macos universal binaries build (#103) 2023-04-01 20:13:08 +08:00
liwei 5558cac263 fix: task view ui 2023-04-01 19:43:47 +08:00
liwei 4128e718e9 ci: fix web build 2023-04-01 17:05:51 +08:00
liwei 6d017b18f5 fix: version check 2023-04-01 16:53:43 +08:00
liwei 35eb089178 ci: fix web build 2023-04-01 16:37:42 +08:00
liwei b5f78f0fbe ci: fix docker build 2023-04-01 16:32:57 +08:00
liwei 628d73f90d ci: fix android build 2023-04-01 16:15:34 +08:00
liwei 7d2d84d844 fix: home page menu icon 2023-04-01 16:09:36 +08:00
liwei 1cbe58d760 ci: fix macos arm build 2023-04-01 15:56:07 +08:00
liwei c77a4094a3 fix: logger file output 2023-04-01 15:52:12 +08:00
Li Wei 33f6325bf7 feat: new version check (#102) 2023-04-01 15:33:58 +08:00
Li Wei 701ea59035 feat: smooth speed calculation (#99) 2023-03-09 10:35:10 +08:00
pharaoh2012 e77441f0de docs: correct the official website link (#98) 2023-03-07 11:01:18 +08:00
Li Wei dd46e7a3eb fix: crash caused by non-resumable http download resources (#97) 2023-03-06 21:09:31 +08:00
liwei f42838810a refactor: common message dialog method 2023-03-06 21:02:24 +08:00
liwei 7fdba12376 fix: tracker cdn retry issue 2023-03-06 17:07:00 +08:00
liwei 20130ae6d0 fix: error code 2023-03-05 15:37:04 +08:00
liwei 4598e9b351 ci: fix release action permissions 2023-03-05 12:10:32 +08:00
Li Wei 2e639e0fef feat: api result add code (#96) 2023-03-05 11:54:39 +08:00
liwei dbbbc204fb doc: add official website link 2023-03-04 13:37:20 +08:00
liwei caab121cc2 ci: fix flutter linux build tool 2023-03-02 14:05:22 +08:00
Li Wei c637b36094 ci: flutter build check (#95) 2023-03-02 13:57:00 +08:00
Neginegiandwuke2022 38ba901956 fix: fluent system theme mode (#94)
* Fix: fluent theme mode fix

---------

Co-authored-by: wuke2022 <[email protected]>
2023-02-28 01:53:37 +08:00
Neginegiandwuke2022 8bae1a5850 feat: complete tree view (#93)
* feat: complete tree view

---------

Co-authored-by: wuke2022 <[email protected]>
2023-02-23 21:34:39 +08:00
Neginegiandwuke2022 56375c0edb fix: treeview,style,file picker (#91)
* fix: tree view,style,file picker

Co-authored-by: wuke2022 <[email protected]>
2023-02-22 20:06:28 +08:00
Neginegiandwuke2022 5b69472ac8 fix: style, performance ,loading bug (#90)
* refactor project in get-cli standard

* use local trackers when github is not connectable
upgrade stylish_bottom_bar to 1.0.0
responsive view for home_view
treeview for file_list_vie
fluent ui integrating

* improve responsive view.
treeView add light theme.fix overflow bugs

* responsive view,treeView,file select
downloaded and downloading pages
file extension icons
fix some minor bugs

* use local trackers when github is not connectable
upgrade stylish_bottom_bar to 1.0.0
responsive view for home_view
treeview for file_list_vie
fluent ui integrating

* improve responsive view.
treeView add light theme.fix overflow bugs

* bottom bar index fix

* refactor project in get-cli standard

* fix:locale fix

* fix: task list stack progress style
fix: translation
fix: tree view
fix: confirm button loading
remove: stylish_bottom_bar(dark mode not supported)

* fix: task list stack progress style
fix: translation
fix: tree view
fix: confirm button loading
remove: stylish_bottom_bar(dark mode not supported)

---------

Co-authored-by: wuke2022 <[email protected]>
2023-02-21 20:17:17 +08:00
Neginegiandwuke2022 dbc9c4c121 fix docs and locales (#86)
* refactor project in get-cli standard

* use local trackers when github is not connectable
upgrade stylish_bottom_bar to 1.0.0
responsive view for home_view
treeview for file_list_vie
fluent ui integrating

* improve responsive view.
treeView add light theme.fix overflow bugs

* responsive view,treeView,file select
downloaded and downloading pages
file extension icons
fix some minor bugs

* use local trackers when github is not connectable
upgrade stylish_bottom_bar to 1.0.0
responsive view for home_view
treeview for file_list_vie
fluent ui integrating

* improve responsive view.
treeView add light theme.fix overflow bugs

* bottom bar index fix

* refactor project in get-cli standard

* fix:locale fix

* docs: add detailed bash for build and dev

---------

Co-authored-by: wuke2022 <[email protected]>
2023-02-19 23:47:28 +08:00
Neginegiandwuke2022 c0e24ce869 fix locale in get-cli standard (#81)
* refactor project in get-cli standard

* use local trackers when github is not connectable
upgrade stylish_bottom_bar to 1.0.0
responsive view for home_view
treeview for file_list_vie
fluent ui integrating

* improve responsive view.
treeView add light theme.fix overflow bugs

* responsive view,treeView,file select
downloaded and downloading pages
file extension icons
fix some minor bugs

* use local trackers when github is not connectable
upgrade stylish_bottom_bar to 1.0.0
responsive view for home_view
treeview for file_list_vie
fluent ui integrating

* improve responsive view.
treeView add light theme.fix overflow bugs

* bottom bar index fix

* refactor project in get-cli standard

---------

Co-authored-by: wuke2022 <[email protected]>
2023-02-19 21:22:20 +08:00
Neginegiandwuke2022 74c24ad860 Refactor project in get-cli standard (#80)
* refactor project in get-cli standard

* use local trackers when github is not connectable
upgrade stylish_bottom_bar to 1.0.0
responsive view for home_view
treeview for file_list_vie
fluent ui integrating

* improve responsive view.
treeView add light theme.fix overflow bugs

* responsive view,treeView,file select
downloaded and downloading pages
file extension icons
fix some minor bugs

* use local trackers when github is not connectable
upgrade stylish_bottom_bar to 1.0.0
responsive view for home_view
treeview for file_list_vie
fluent ui integrating

* improve responsive view.
treeView add light theme.fix overflow bugs

* bottom bar index fix

* refactor project in get-cli standard

---------

Co-authored-by: wuke2022 <[email protected]>
2023-02-19 20:06:28 +08:00
wuke2022 9b1ed15fea feat: ui improvement
flutter 3.7 compatibility
responsive view
beautiful progress bar
select file in treeview
downlading and downloaded page
2023-02-16 11:34:42 +08:00
liwei a875a9258e refactor: change repo url 2023-02-11 21:37:05 +08:00
liwei 0d2ac10af8 fix: rename logic 2023-02-10 21:40:16 +08:00
liwei 978b8bc7cb docs: add full name 2023-02-10 16:20:55 +08:00
liwei 50c3713c90 docs: donate link hash 2023-02-10 16:16:00 +08:00
liwei 2639b6bc65 docs: badge sort 2023-02-10 16:09:01 +08:00
liwei 8b29ceb151 docs: add donate badge 2023-02-10 16:07:43 +08:00
liwei e1c14ad9f8 docs: add donate info 2023-02-10 15:42:29 +08:00
liwei e4f86097e8 fix: bt resolve 2023-02-10 15:27:15 +08:00
Li Wei 0515ce6081 feat: auto rename on duplicate (#76) 2023-02-10 15:18:31 +08:00
liwei 048554df4d ci: fix 2023-02-08 17:29:23 +08:00
Li Wei 2d02486ca6 ci: windows setup add chinese (#75) 2023-02-08 17:18:39 +08:00
liwei 3523f5855a fix: api ok nil response 2023-02-08 14:59:59 +08:00
liwei aa9577a2e0 fix: ignore broken task data 2023-02-08 14:53:49 +08:00
liwei efc9dcca0b fix: download dir config 2023-02-07 23:53:50 +08:00
liwei b062336c09 docs: update CONTRIBUTING.md 2023-02-07 23:30:26 +08:00
liwei baf88a5cb7 ci: build only manual trigger 2023-02-07 18:28:14 +08:00
liwei e208014d34 ci: fix windows 2023-02-07 18:24:03 +08:00
liwei 3ecedd60fd ci: fix 2023-02-07 15:33:02 +08:00
liwei 6fd68e4752 ci: fix input 2023-02-07 15:13:51 +08:00
Li Wei c2910b0e8c ci: get last release include draft (#74) 2023-02-07 15:10:31 +08:00
Li Wei 8ed51787ad feat: add cdn for tracker subscribe urls (#73) 2023-02-07 14:18:59 +08:00
liwei 2e5fd3017c fix: bt fetcher restore 2023-02-06 22:21:25 +08:00
Li Wei 027d29e834 fix: windows version (#72) 2023-02-06 21:06:56 +08:00
Li Wei 1a3c8c026b feat: reuse fetcher (#71) 2023-02-06 20:58:13 +08:00
Persian Prince 17cb59c606 feat: add farsi languages (#70) 2023-02-03 21:35:23 +08:00
liwei e3828c2982 ci: fix 2022-12-17 13:09:15 +08:00
liwei 8cef0bb727 ci: fix android sign 2022-12-17 10:58:32 +08:00
Li Wei 21ec1e4539 fix: follow android 11 storage permissions (#66) 2022-12-16 21:43:06 +08:00
levi dda44cc770 fix: docker tag 2022-12-16 15:24:41 +08:00
levi 78911b2aa9 ci: fix docker build 2022-12-16 15:10:34 +08:00
Li Wei 4935dcd0e6 ci: fix 2022-12-16 15:02:33 +08:00
Li Wei e8fe6ad550 ci: docker supports more platforms (#64) 2022-12-16 14:56:56 +08:00
Li Wei db4003ad4f fix: speed calculate (#63) 2022-12-16 14:53:07 +08:00
Li Wei 0a4be165a5 fix: http download fail (#61) 2022-12-14 12:12:39 +08:00
levi c62867e3ef chore: fix docker-compose command 2022-12-13 15:00:32 +08:00
levi 6df4959845 ci: fix 2022-12-13 14:49:53 +08:00
levi e0b18013be chore: release v1.2.0 2022-12-13 14:48:01 +08:00
levi d205e3b372 feat: enable ru_RU 2022-12-13 14:47:08 +08:00
azatzin30 719daf054b feat: Translation to Russian language (#60) 2022-12-13 14:42:08 +08:00
levi a5f72a22c3 fix: i18n 2022-12-13 14:39:51 +08:00
levi bf5c7655f2 test: remove fail code 2022-12-12 16:06:00 +08:00
Li Wei 691c2f496e feat: support custom tracker url (#59) 2022-12-12 15:58:11 +08:00
gesneriana 9ef3405e43 fix: missing single quotes (#57) 2022-11-16 13:34:15 +08:00
azatzin30 215faa0bf0 feat: Added Russian language (#55) 2022-11-11 22:17:38 +08:00
cgcel 695da7ea87 feat: support docker compose (#54) 2022-11-10 14:13:34 +08:00
liwei 7c6c361d30 fix: docker dist copy 2022-11-08 22:55:56 +08:00
liwei 872ba0ba87 doc: fix docker run command 2022-11-08 22:36:47 +08:00
monkeyWie 93c14dcee5 fix: ip bind (#50) 2022-11-08 22:29:33 +08:00
liwei 1af0b2175c ci: path include Dockerfile 2022-11-08 21:50:45 +08:00
monkeyWie ee81ebffef fix: docker executable (#49) 2022-11-08 21:49:02 +08:00
levi dc00fd2543 doc: fix 2022-11-08 15:02:58 +08:00
levi 11015c7be1 doc: mac repair tip 2022-11-08 14:35:51 +08:00
monkeyWie 57befd2c03 fix: web download dir setting (#46) 2022-11-08 14:31:49 +08:00
monkeyWie 35156ed968 ci: remove windows zip root folder 2022-11-07 20:29:10 +08:00
levi ee6c36174f ci: fix version 2022-11-07 17:25:23 +08:00
levi fdfd95555d ci: fix docker build 2022-11-07 17:20:31 +08:00
levi 7ec824edda ci: fix 2022-11-07 17:13:00 +08:00
levi 1f8b1be669 ci: fix action version 2022-11-07 16:49:58 +08:00
levi b0bcfeae34 ci: fix action version 2022-11-07 16:48:02 +08:00
levi 10b346a326 ci: fix 2022-11-07 16:45:44 +08:00
levi a85af39b35 ci: fix build 2022-11-07 16:43:55 +08:00
levi 576f0d2e24 ci: fix docker tag 2022-11-07 16:41:52 +08:00
monkeyWie 95eb3a427c feat: docker support (#43) 2022-11-07 16:32:52 +08:00
monkeyWie e9442edd94 feat: support drag torrent file (#42) 2022-11-07 14:12:12 +08:00
monkeyWie 77e59f7376 feat: i18n support (#40) 2022-11-05 17:04:22 +08:00
monkeyWie 135fba1b54 doc: fix md 2022-11-05 13:16:57 +08:00
liwei ff813e3bc9 ci: build on published 2022-11-05 11:27:56 +08:00
Trim21 babfe79023 Update flags.go 2022-11-05 10:43:07 +08:00
liwei 89ac52bebd ci: path ignore 2022-11-04 21:48:22 +08:00
liwei 15e1c2f2d5 chore: git ignore generated_plugin 2022-11-04 21:43:31 +08:00
levi 203f0ccc82 chore: fix go version 2022-11-04 17:13:32 +08:00
levi 2422bd2c66 chore: update md 2022-11-04 16:38:07 +08:00
levi 0306a1ed6b chore: en link 2022-11-04 16:26:19 +08:00
levi 8a94650370 chore: en readme 2022-11-04 16:25:57 +08:00
levi 837a193e96 chore: add discord link 2022-11-04 15:40:31 +08:00
levi fcf0746f9a ci: fix version 2022-11-04 15:12:38 +08:00
levi 9eb466ccd3 ci: path ignore 2022-11-04 15:11:06 +08:00
levi c54f22613e chore: issue tpl 2022-11-04 15:06:54 +08:00
177 changed files with 9625 additions and 2803 deletions

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
## Donate
Thank you for your support! 🙏
<div style="text-align: center;">
<table>
<tr>
<th>微信</th>
<th>支付宝</th>
<th>PayPal</th>
<th>Ko-fi</th>
</tr>
<tr>
<td><img src="/.donate/qr/wechatpay.jpg" alt="wechatpay" width="300" /></td>
<td><img src="/.donate/qr/alipay.jpg" alt="alipay" width="300" /></td>
<td><a href="https://paypal.me/monkeyWie" target="_blank">Click Me</a></td>
<td><a href="https://ko-fi.com/gopeed" target="_blank">Click Me</a></td>
</table>
</div>
### Crypto
- **BTC**: 35jYkPN8bgkh7AWjt3B1VgMQHqtGYcvF6p
- **ETH:** 0x928a7fd2398c941Ac6713704e606Fa414595e3A0
- **Matic:** 0x928a7fd2398c941Ac6713704e606Fa414595e3A0
- **BNB:** 0x928a7fd2398c941Ac6713704e606Fa414595e3A0
- **USDT(ERC20)**: 0x928a7fd2398c941Ac6713704e606Fa414595e3A0
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
#github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
#patreon: # Replace with a single Patreon username
#open_collective: # Replace with a single Open Collective username
#ko_fi: # Replace with a single Ko-fi username
#tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
#community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
#liberapay: # Replace with a single Liberapay username
#issuehunt: # Replace with a single IssueHunt username
#otechie: # Replace with a single Otechie username
#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
# github: monkeyWie
ko_fi: gopeed
custom: https://github.com/GopeedLab/gopeed/blob/main/.donate/index.md#donate
+8
View File
@@ -0,0 +1,8 @@
<!--
Please use the following template to create an issue.
-->
### Description(required)
### App Version(required)
### OS Version(required)
### Snapshots
### Log
+2 -2
View File
@@ -1,5 +1,5 @@
name-template: 'v1.0.0 🌈'
tag-template: 'v1.0.0'
name-template: 'v$NEXT_PATCH_VERSION 🌈'
tag-template: 'v$NEXT_PATCH_VERSION'
categories:
- title: '🆕 Features'
labels:
+370
View File
@@ -0,0 +1,370 @@
name: build
on:
workflow_dispatch:
env:
GO_VERSION: 1.19
FLUTTER_VERSION: 3.7.12
permissions:
contents: write
jobs:
get-release:
runs-on: ubuntu-latest
outputs:
tag_name: ${{ steps.get-release.outputs.tag_name }}
upload_url: ${{ steps.get-release.outputs.upload_url }}
steps:
- uses: monkeyWie/get-latest-release@v1
id: get-release
with:
myToken: ${{ github.token }}
build-windows:
runs-on: windows-latest
needs: [get-release]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.tag_name }}
run: |
Invoke-WebRequest -Uri "https://github.com/jrsoftware/issrc/raw/main/Files/Languages/Unofficial/ChineseSimplified.isl" -OutFile "ChineseSimplified.isl"
mv ChineseSimplified.isl "C:\Program Files (x86)\Inno Setup 6\Languages\"
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop
cd ui/flutter
flutter build windows
$mingw = "C:\ProgramData\Chocolatey\lib\mingw\tools\install\mingw64\bin"
$system = "C:\Windows\System32"
$release = "build\windows\runner\Release\"
cp $mingw\libstdc++-6.dll $release
cp $mingw\libgcc_s_seh-1.dll $release
cp $mingw\libwinpthread-1.dll $release
cp $system\msvcp140.dll $release
cp $system\vcruntime140.dll $release
cp $system\vcruntime140_1.dll $release
New-Item -Path build\windows\Output -ItemType Directory
Compress-Archive -Path "$release*" -DestinationPath "build\windows\Output\Gopeed-$env:VERSION-windows-amd64-portable.zip"
cd build/windows
echo @"
; Script generated by the Inno Setup Script Wizard.
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#define MyAppName "Gopeed"
#define MyAppVersion "$($env:VERSION.substring(1))"
#define MyAppPublisher "monkeyWie"
#define MyAppURL "https://gopeed.com"
#define MyAppExeName "gopeed.exe"
[Setup]
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
AppId={{5960F34D-1E42-402C-8C85-DE2FF24CBAE4}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
;AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\gopeed
DisableProgramGroupPage=yes
LicenseFile=..\..\..\..\LICENSE
; Remove the following line to run in administrative install mode (install for all users.)
PrivilegesRequired=lowest
OutputBaseFilename=gopeed
SetupIconFile=..\..\assets\icon\icon.ico
Compression=lzma
SolidCompression=yes
WizardStyle=modern
LanguageDetectionMethod=uilanguage
ShowLanguageDialog=yes
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: ".\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
[UninstallDelete]
Type: filesandordirs; Name: "{app}"
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
"@ > setup.iss
iscc.exe setup.iss
mv "Output\gopeed.exe" "Output\Gopeed-$env:VERSION-windows-amd64.exe"
Compress-Archive -Path "Output\Gopeed-$env:VERSION-windows-amd64.exe" -DestinationPath "Output\Gopeed-$env:VERSION-windows-amd64.zip"
Remove-Item -Path "Output\Gopeed-$env:VERSION-windows-amd64.exe"
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.get-release.outputs.upload_url }}
asset_path: ui/flutter/build/windows/Output/*
overwrite: true
build-macos-arm64-lib:
runs-on: ubuntu-latest
needs: [get-release]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- name: Build
run: |
docker pull techknowlogick/xgo:go-1.19.x
go install src.techknowlogick.com/xgo@latest
xgo --targets=darwin/arm64 -tags="nosqlite" -ldflags="-w -s" -buildmode=c-shared -pkg=bind/desktop -out=libgopeed .
mv libgopeed-*.dylib libgopeed.dylib
- uses: actions/upload-artifact@v3
with:
name: macos-arm64-lib
path: libgopeed.dylib
build-macos:
runs-on: macos-latest
needs: [get-release, build-macos-arm64-lib]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- uses: actions/setup-node@v3
with:
node-version: 16
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Install appdmg
run: npm install -g appdmg
- uses: actions/download-artifact@v3
with:
name: macos-arm64-lib
path: ui/flutter/lib-arm64
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.tag_name }}
run: |
PROJECT_DIR=$(pwd)
# amd64 lib
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/lib-amd64/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
# universal binary
mkdir -p ui/flutter/macos/Frameworks
cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/macos/Frameworks/amd64-lib
cp ui/flutter/lib-arm64/libgopeed.dylib ui/flutter/macos/Frameworks/arm64-lib
cd ui/flutter/macos/Frameworks
lipo -create -output libgopeed.dylib amd64-lib arm64-lib
cd $PROJECT_DIR/ui/flutter
flutter build macos
cd build/macos/Build/Products/Release
cat>appdmg.json<<EOF
{
"title": "Gopeed",
"icon": "Gopeed.app/Contents/Resources/AppIcon.icns",
"contents": [
{ "x": 448, "y": 344, "type": "link", "path": "/Applications" },
{ "x": 192, "y": 344, "type": "file", "path": "Gopeed.app" }
]
}
EOF
mkdir dist
appdmg appdmg.json dist/Gopeed-$VERSION-macos.dmg
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.get-release.outputs.upload_url }}
asset_path: ui/flutter/build/macos/Build/Products/Release/dist/*
overwrite: true
build-linux:
runs-on: ubuntu-latest
needs: [get-release]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- run: |
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.tag_name }}
run: |
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
cd ui/flutter
flutter build linux
mkdir -p debian/gui
cp assets/icon/icon_1024.png debian/gui/gopeed.png
cat>debian/debian.yaml<<EOF
flutter_app:
command: gopeed
arch: x64
parent: /usr/local/lib
control:
Package: gopeed
Version: ${VERSION:1}
Architecture: amd64
Essential: no
Priority: optional
Depends:
Maintainer: monkeyWie
Description: High speed downloader that supports all platforms.
EOF
cat>debian/gui/gopeed.desktop<<EOF
[Desktop Entry]
Version=${VERSION:1}
Name=Gopeed
GenericName=Gopeed
Comment=High speed downloader that supports all platforms.
Terminal=false
Type=Application
Categories=Utility
Keywords=Flutter;Downloader;
EOF
dart pub global activate flutter_to_debian
flutter_to_debian
mv debian/packages/gopeed*.deb debian/packages/Gopeed-$VERSION-linux-amd64.deb
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.get-release.outputs.upload_url }}
asset_path: ui/flutter/debian/packages/*
overwrite: true
build-android:
runs-on: ubuntu-latest
needs: [get-release]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- uses: actions/setup-java@v3
with:
distribution: "zulu"
java-version: "11"
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.tag_name }}
APK_KEYSTORE : ${{ secrets.APK_KEYSTORE }}
APK_KEY_PASSWORD : ${{ secrets.APK_KEY_PASSWORD }}
APK_STORE_PASSWORD : ${{ secrets.APK_STORE_PASSWORD }}
run: |
go install golang.org/x/mobile/cmd/gomobile@latest
go get golang.org/x/mobile/bind
gomobile init
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 19 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
cd ui/flutter
echo $APK_KEYSTORE | base64 -di > android/app/upload-keystore.jks
flutter build apk
mkdir dist
cp build/app/outputs/flutter-apk/app-release.apk dist/Gopeed-$VERSION-android.apk
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.get-release.outputs.upload_url }}
asset_path: ui/flutter/dist/*
overwrite: true
build-web:
runs-on: ubuntu-latest
needs: [get-release]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.tag_name }}
run: |
cd ui/flutter
flutter build web
cd ../../
cp -r ui/flutter/build/web cmd/web/dist
mkdir -p dist/zip
goos_arr=(windows darwin linux)
goarch_arr=(386 amd64 arm64)
export CGO_ENABLED=0
for goos in "${goos_arr[@]}"; do
for goarch in "${goarch_arr[@]}"; do
goos_name=$goos
if [ $goos = "darwin" ]; then
goos_name="macos"
fi
name=gopeed-web-$VERSION-$goos_name-$goarch
dir="dist/$name/"
(GOOS=$goos GOARCH=$goarch go build -tags nosqlite,web -ldflags="-s -w" -o $dir github.com/GopeedLab/gopeed/cmd/web \
&& cd $dir \
&& file=$(ls -AU | head -1) \
&& mkdir $name \
&& mv $file $name/$(echo $file | sed -e "s/web/gopeed/g") \
&& zip -r ../zip/$name.zip * \
&& cd ../..) \
|| true
done
done
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.get-release.outputs.upload_url }}
asset_path: dist/zip/*
overwrite: true
build-docker:
runs-on: ubuntu-latest
needs: [get-release]
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: liwei2633
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v2
env:
VERSION: ${{ needs.get-release.outputs.tag_name }}
with:
push: true
platforms: |
linux/386
linux/amd64
linux/arm64
tags: |
liwei2633/gopeed:latest
liwei2633/gopeed:${{ env.VERSION }}
+19 -307
View File
@@ -4,10 +4,23 @@ on:
push:
branches:
- main
paths-ignore:
- '_docs/**'
- '_examples/**'
- 'README*.md'
paths:
- "bind/**"
- "cmd/**"
- "internal/**"
- "pkg/**"
- "ui/**"
- ".github/workflows/release.yml"
- ".github/release-drafter.yml"
- "go.mod"
- "go.sum"
- "Dockerfile"
env:
GO_VERSION: 1.19
permissions:
contents: write
jobs:
release:
@@ -18,311 +31,10 @@ jobs:
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: '^1.19'
go-version: ${{ env.GO_VERSION }}
- uses: release-drafter/release-drafter@v5
id: create_release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-windows:
runs-on: windows-latest
needs: [ release ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: '^1.19'
cache: true
- uses: subosito/flutter-action@v2
- name: Build
env:
VERSION: ${{ needs.release.outputs.tag_name }}
run: |
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/monkeyWie/gopeed/bind/desktop
cd ui/flutter
flutter build windows
$mingw = "C:\ProgramData\Chocolatey\lib\mingw\tools\install\mingw64\bin"
$system = "C:\Windows\System32"
$release = "build\windows\runner\Release\"
cp $mingw\libstdc++-6.dll $release
cp $mingw\libgcc_s_seh-1.dll $release
cp $mingw\libwinpthread-1.dll $release
cp $system\msvcp140.dll $release
cp $system\vcruntime140.dll $release
cp $system\vcruntime140_1.dll $release
cd build/windows
echo @"
; Script generated by the Inno Setup Script Wizard.
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#define MyAppName "Gopeed"
#define MyAppVersion "$($env:VERSION.substring(1))"
#define MyAppPublisher "monkeyWie"
#define MyAppURL "https://gopeed.com"
#define MyAppExeName "gopeed.exe"
[Setup]
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
AppId={{5960F34D-1E42-402C-8C85-DE2FF24CBAE4}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
;AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\gopeed
DisableProgramGroupPage=yes
LicenseFile=..\..\..\..\LICENSE
; Remove the following line to run in administrative install mode (install for all users.)
PrivilegesRequired=lowest
OutputBaseFilename=gopeed
SetupIconFile=..\..\assets\icon\icon.ico
Compression=lzma
SolidCompression=yes
WizardStyle=modern
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: ".\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
"@ > setup.iss
iscc.exe setup.iss
mv "Output\gopeed.exe" "Output\Gopeed-$env:VERSION-windows-amd64.exe"
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: ui/flutter/build/windows/Output/*
overwrite: true
build-macos-arm64-lib:
runs-on: ubuntu-latest
needs: [ release ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: '^1.19'
cache: true
- run: mkdir -p ~/image-cache
- id: image-cache
uses: actions/cache@v3
with:
path: ~/image-cache
key: image-cache-xgo-v1.19
- if: steps.image-cache.outputs.cache-hit != 'true'
run: |
docker pull techknowlogick/xgo:go-1.19.x
docker save -o ~/image-cache/xgo.tar techknowlogick/xgo:go-1.19.x
- if: steps.image-cache.outputs.cache-hit == 'true'
run: docker load -i ~/image-cache/xgo.tar
- name: Build
run: |
go install src.techknowlogick.com/xgo@latest
xgo --targets=darwin/arm64 -tags="nosqlite" -ldflags="-w -s" -buildmode=c-shared -pkg=bind/desktop -out=libgopeed .
mv libgopeed-*.dylib libgopeed.dylib
- uses: actions/upload-artifact@v3
with:
name: macos-arm64-lib
path: libgopeed.dylib
build-macos:
runs-on: macos-latest
needs: [ release, build-macos-arm64-lib ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: '^1.19'
cache: true
- uses: actions/setup-node@v3
with:
node-version: 16
- uses: subosito/flutter-action@v2
- name: Install appdmg
run: npm install -g appdmg
- uses: actions/download-artifact@v3
with:
name: macos-arm64-lib
path: ui/flutter/macos/Frameworks
- name: Build
env:
VERSION: ${{ needs.release.outputs.tag_name }}
run: |
PROJECT_DIR=$(pwd)
cd ui/flutter
flutter build macos
cd build/macos/Build/Products/Release
cat>appdmg.json<<EOF
{
"title": "Gopeed",
"icon": "Gopeed.app/Contents/Resources/AppIcon.icns",
"contents": [
{ "x": 448, "y": 344, "type": "link", "path": "/Applications" },
{ "x": 192, "y": 344, "type": "file", "path": "Gopeed.app" }
]
}
EOF
mkdir dist
appdmg appdmg.json dist/Gopeed-$VERSION-macos-arm64.dmg
# amd64
cd $PROJECT_DIR
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o bin/libgopeed.dylib github.com/monkeyWie/gopeed/bind/desktop
cp bin/libgopeed.dylib ui/flutter/build/macos/Build/Products/Release/Gopeed.app/Contents/Frameworks/
cd ui/flutter/build/macos/Build/Products/Release
appdmg appdmg.json dist/Gopeed-$VERSION-macos-amd64.dmg
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: ui/flutter/build/macos/Build/Products/Release/dist/*
overwrite: true
build-linux:
runs-on: ubuntu-latest
needs: [ release ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: '^1.19'
cache: true
- uses: subosito/flutter-action@v2
- run: |
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev
- name: Build
env:
VERSION: ${{ needs.release.outputs.tag_name }}
run: |
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/monkeyWie/gopeed/bind/desktop
cd ui/flutter
flutter build linux
mkdir -p debian/gui
cp assets/icon/icon_1024.png debian/gui/gopeed.png
cat>debian/debian.yaml<<EOF
flutter_app:
command: gopeed
arch: x64
parent: /usr/local/lib
control:
Package: gopeed
Version: ${VERSION:1}
Architecture: amd64
Essential: no
Priority: optional
Depends:
Maintainer: monkeyWie
Description: High speed downloader that supports all platforms.
EOF
cat>debian/gui/gopeed.desktop<<EOF
[Desktop Entry]
Version=${VERSION:1}
Name=Gopeed
GenericName=Gopeed
Comment=High speed downloader that supports all platforms.
Terminal=false
Type=Application
Categories=Utility
Keywords=Flutter;Downloader;
EOF
dart pub global activate flutter_to_debian
flutter_to_debian
mv debian/packages/gopeed*.deb debian/packages/Gopeed-$VERSION-linux-amd64.deb
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: ui/flutter/debian/packages/*
overwrite: true
build-android:
runs-on: ubuntu-latest
needs: [ release ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: '^1.19'
cache: true
- uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '11'
- uses: subosito/flutter-action@v2
- name: Build
env:
VERSION: ${{ needs.release.outputs.tag_name }}
run: |
mkdir ui/flutter/android/app/libs
go install golang.org/x/mobile/cmd/gomobile@latest
gomobile init
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 19 -javapkg=com.gopeed github.com/monkeyWie/gopeed/bind/mobile
cd ui/flutter
flutter build apk
mkdir dist
cp build/app/outputs/flutter-apk/app-release.apk dist/Gopeed-$VERSION-android.apk
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: ui/flutter/dist/*
overwrite: true
build-web:
runs-on: ubuntu-latest
needs: [ release ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: '^1.19'
cache: true
- uses: subosito/flutter-action@v2
- name: Build
env:
VERSION: ${{ needs.release.outputs.tag_name }}
run: |
cd ui/flutter
flutter build web
cd ../../
cp -r ui/flutter/build/web cmd/web/dist
mkdir -p dist/zip
goos_arr=(windows darwin linux)
goarch_arr=(386 amd64 arm64)
export CGO_ENABLED=0
for goos in "${goos_arr[@]}"; do
for goarch in "${goarch_arr[@]}"; do
name=gopeed-web-$VERSION-$goos-$goarch
dir="dist/$name/"
(GOOS=$goos GOARCH=$goarch go build -tags nosqlite,web -ldflags="-s -w" -o $dir github.com/monkeyWie/gopeed/cmd/web \
&& cd $dir \
&& file=$(ls -AU | head -1) \
&& mkdir $name \
&& mv $file $name/$(echo $file | sed -e "s/web/gopeed/g") \
&& zip -r ../zip/$name.zip * \
&& cd ../..) \
|| true
done
done
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ needs.release.outputs.upload_url }}
asset_path: dist/zip/*
overwrite: true
+52 -36
View File
@@ -4,61 +4,77 @@ on:
pull_request:
branches:
- main
paths-ignore:
- '.github/workflows/release.yml'
- '.github/release-drafter.yml'
- '_docs/**'
- '_examples/**'
- 'README*.md'
paths:
- "bind/**"
- "cmd/**"
- "internal/**"
- "pkg/**"
- "ui/**"
- ".github/workflows/test.yml"
- "go.mod"
- "go.sum"
push:
branches:
- main
paths-ignore:
- '.github/workflows/release.yml'
- '.github/release-drafter.yml'
- '_docs/**'
- '_examples/**'
- 'README*.md'
paths:
- "bind/**"
- "cmd/**"
- "internal/**"
- "pkg/**"
- "ui/**"
- ".github/workflows/test.yml"
- "go.mod"
- "go.sum"
env:
GO_VERSION: 1.19
FLUTTER_VERSION: 3.7.12
jobs:
# lint:
# name: Lint
# runs-on: ubuntu-latest
# steps:
# - uses: actions/setup-go@v2
# with:
# go-version: '^1.19'
# - uses: actions/checkout@v2
# - name: Lint Go Code
# run: |
# export PATH=$PATH:$(go env GOPATH)/bin # temporary fix. See https://github.com/actions/setup-go/issues/14
# go get -u golang.org/x/lint/golint
# golint -set_exit_status ./...
# lint:
# name: Lint
# runs-on: ubuntu-latest
# steps:
# - uses: actions/setup-go@v2
# with:
# go-version: '^1.19'
# - uses: actions/checkout@v2
# - name: Lint Go Code
# run: |
# export PATH=$PATH:$(go env GOPATH)/bin # temporary fix. See https://github.com/actions/setup-go/issues/14
# go get -u golang.org/x/lint/golint
# golint -set_exit_status ./...
test:
name: Test
name: Test check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: '^1.19'
cache: true
go-version: ${{ env.GO_VERSION }}
- name: Run Unit tests.
run: go test $(go list ./... | grep -v /cmd/ | grep -v /bind/ ) -v -covermode=count -coverprofile=coverage.txt
- uses: codecov/codecov-action@v3
with:
files: ./coverage.txt
fail_ci_if_error: true
build:
name: Build
name: Build check
runs-on: ubuntu-latest
# needs: [lint, test]
# needs: [lint, test]
needs: [test]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: '^1.19'
cache: true
go-version: ${{ env.GO_VERSION }}
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
- run: |
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev
- name: Build
run: go build github.com/monkeyWie/gopeed/cmd/gopeed
run: |
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
cd ui/flutter
flutter build linux
+54
View File
@@ -0,0 +1,54 @@
# Gopeed contributors guide
Firstly, thank you for your interest in contributing to Gopeed. This guide will help you better
participate in the development of Gopeed.
## Branch description
This project only has one main branch, namely the `main` branch. If you want to participate in the
development of Gopeed, please fork this project first, and then develop in your fork project. After
development is completed, submit a PR to this project and merge it into the `main` branch.
## Local development
It is recommended to develop and debug through the web. First, start the backend service, and start
it by the command line `go run cmd/api/main.go`, the default port of the service is `9999`, and then
start the front-end flutter project in `debug` mode to run.
## Translation
The internationalization files of Gopeed are located in the `ui/flutter/assets/locales` directory.
You only need to add the corresponding language file in this directory.
Generate locales after you edit locales:
```
get generate locales
```
Please refer to `en_us.dart` for translation.
## flutter development
Don't forget to run`dart format ./ui/flutter`before you commit to keep your code in standard dart format
Turn on build_runner watcher if you want to edit api/models:
```
flutter pub run build_runner watch
```
get-cli commands usages:
```
create:
controller: Generate controller
page: Use to generate pages
view: Generate view
generate:
locales: Generate translation file from json files
model: generate Class model from json
```
+48
View File
@@ -0,0 +1,48 @@
# Gopeed 贡献指南
首先感谢您对贡献代码感兴趣,这份指南将帮助您更好的参与到 Gopeed 的开发中来。
## 分支说明
本项目只有一个主分支,即 `main` 分支,如果您想要参与到 Gopeed 的开发中来,请先 fork 本项目,然后在您的 fork 项目中进行开发,开发完成后再向本项目提交
PR,合并到 `main` 分支。
## 本地开发
建议通过 web 端进行开发调试,首先启动后端服务,通过命令行 `go run cmd/api/main.go` 启动 ,服务启动默认端口为 `9999`,然后以 `debug` 模式启动前端
flutter 项目即可运行。
## 翻译
Gopeed 的国际化文件位于 `ui/flutter/assets/locales` 目录下,只需要在该目录下添加对应的语言文件即可。
编辑locales后请运行以下命令:
```
get generate locales
```
请注意以 `en_US.json` 为参照进行翻译。
## flutter开发
每次提交前请务必`dart format ./ui/flutter`
如果要编辑api/models,请打开build_runner watcher:
```
flutter pub run build_runner watch
```
适用get-cli 命令:
```
create:
controller: Generate controller
page: Use to generate pages
view: Generate view
generate:
locales: Generate translation file from json files
model: generate Class model from json
```
+21
View File
@@ -0,0 +1,21 @@
FROM cirrusci/flutter:3.7.7 AS flutter
WORKDIR /app
COPY ./ui/flutter/pubspec.yaml ./ui/flutter/pubspec.lock ./
RUN flutter pub get
COPY ./ui/flutter ./
RUN flutter build web
FROM golang:1.19.3 AS go
WORKDIR /app
COPY ./go.mod ./go.sum ./
RUN go mod download
COPY . .
COPY --from=flutter /app/build/web ./cmd/web/dist
RUN CGO_ENABLED=0 go build -tags nosqlite,web -ldflags="-s -w" -o dist/gopeed github.com/GopeedLab/gopeed/cmd/web
FROM alpine:3.14.2
LABEL maintainer="monkeyWie"
WORKDIR /app
COPY --from=go /app/dist/gopeed ./
EXPOSE 9999
ENTRYPOINT ["./gopeed"]
+87 -37
View File
@@ -1,16 +1,22 @@
[![Test Status](https://github.com/monkeyWie/gopeed/workflows/test/badge.svg)](https://github.com/monkeyWie/gopeed/actions?query=workflow%3Atest)
[![Codecov](https://codecov.io/gh/monkeyWie/gopeed/branch/main/graph/badge.svg)](https://codecov.io/gh/monkeyWie/gopeed)
[![Release](https://img.shields.io/github/release/monkeyWie/gopeed.svg?style=flat-square)](https://github.com/monkeyWie/gopeed/releases)
[![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)
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg?style=flat-square)](https://github.com/GopeedLab/gopeed/releases)
[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://github.com/GopeedLab/gopeed/blob/main/.donate/index.md#donate)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
![](_docs/img/banner.png)
## 介绍
[English](/README.md) | [中文](/README_zh-CN.md)
Gopeed 是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet)协议下载,并且支持全平台使用。
## Introduction
## 下载
Gopeed (full name Go Speed), a high-speed downloader developed by `Golang` + `Flutter`, supports (HTTP, BitTorrent, Magnet) protocol, and supports all platforms.
**已支持平台**
Visit ✈ [Official Website](https://gopeed.com) | 📖 [Develop Docs](https://docs.gopeed.com)
## Install
**Supported platforms**
- [x] windows
- [x] macos
@@ -18,50 +24,81 @@ Gopeed 是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、
- [x] android
- [ ] ios
- [x] web
- [x] docker
[点击前往](https://github.com/monkeyWie/gopeed/releases/latest)
[Download](https://github.com/GopeedLab/gopeed/releases/latest)
### 命令行工具
> Tips: If the macos open failed, please execute the `xattr -d com.apple.quarantine /Applications/Gopeed.app` command in the terminal
使用`go install`安装:
### Command tool
use `go install`:
```bash
go install github.com/monkeyWie/gopeed/cmd/gopeed
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
```
## 界面展示
### Docker
#### Start directly
```bash
docker run -d -p 9999:9999 -v /path/to/download:/download liwei2633/gopeed
```
#### Using Docker Compose
```bash
docker-compose up -d
```
#### Access Gopeed
When the docker container is running, you can access the web page through `http://localhost:9999`.
> Tip: Modify the download path to `/download` on the setting page to access the downloaded files on the host.
## Donate
If you like this project, please consider [donating](/.donate/index.md#donate) to support the development of this project, thank you!
## Showcase
![](_docs/img/ui-demo.png)
## 开发
## Development
本项目分为前端和后端两个部分,前端使用`flutter`,后端使用`Golang`,两边通过`http`协议进行通讯,在 unix 系统下,使用的是`unix socket`,在 windows 系统下,使用的是`tcp`协议。
This project is divided into two parts, the front end uses `flutter`, the back end uses `Golang`, and the two sides communicate through the `http` protocol. On the unix system, `unix socket` is used, and on the windows system, `tcp` protocol is used.
> 前端代码位于`ui/flutter`目录下。
> The front code is located in the `ui/flutter` directory.
### 环境要求
### Environment
1. Golang 1.9+
2. Flutter 3.0+
1. Golang 1.19+
2. Flutter 3.7+
### 克隆项目
### Clone
```bash
git clone [email protected]:monkeyWie/gopeed.git
```
### 编译
### Contributing
#### 桌面端
Please refer to [CONTRIBUTING.md](/CONTRIBUTING.md)
首先需要按照[flutter desktop 官网文档](https://docs.flutter.dev/development/platform-integration/desktop)进行环境配置,然后需要准备好`cgo`环境,具体可以自行搜索。
### Build
构建命令:
#### Desktop
First, you need to configure the environment according to the [flutter desktop official website document](https://docs.flutter.dev/development/platform-integration/desktop), and then you need to prepare the `cgo` environment, which can be searched for yourself.
command:
- windows
```bash
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/monkeyWie/gopeed/bind/desktop
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop
cd ui/flutter
flutter build windows
```
@@ -69,7 +106,7 @@ flutter build windows
- macos
```bash
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/monkeyWie/gopeed/bind/desktop
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
cd ui/flutter
flutter build macos
```
@@ -77,44 +114,57 @@ flutter build macos
- linux
```bash
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/monkeyWie/gopeed/bind/desktop
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
cd ui/flutter
flutter build linux
```
#### 移动端
#### Mobile
同样的也是需要准备好`cgo`环境,接着安装`gomobile`:
Same as before, you also need to prepare the `cgo` environment, and then install `gomobile`:
```bash
go install golang.org/x/mobile/cmd/gomobile@latest
go get golang.org/x/mobile/bind
gomobile init
```
构建命令:
command:
- android
```bash
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 19 -javapkg=com.gopeed github.com/monkeyWie/gopeed/bind/mobile
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 19 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
cd ui/flutter
flutter build apk
```
#### Web 端(推荐本地调试使用)
#### Web
Web 端直接与后端 http 服务通讯,不需要额外准备环境。
构建命令:
command:
```bash
cd ui/flutter
flutter build web
cd ../../
rm -rf cmd/web/dist
cp -r ui/flutter/build/web cmd/web/dist
go build -tags nosqlite,web -ldflags="-s -w" -o bin/ github.com/monkeyWie/gopeed/cmd/web
go build -tags nosqlite,web -ldflags="-s -w" -o bin/ github.com/GopeedLab/gopeed/cmd/web
go run cmd/web/main.go
```
## 开源许可
## Thanks
基于 [GPLv3](LICENSE) 协议开源。
### Contributors
<a href="https://github.com/GopeedLab/gopeed/graphs/contributors">
<img src="https://contrib.rocks/image?repo=GopeedLab/gopeed" />
</a>
### JetBrains
[![goland](_docs/img/goland.svg)](https://www.jetbrains.com/?from=gopeed)
## License
[GPLv3](LICENSE)
+169
View File
@@ -0,0 +1,169 @@
[![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)
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg?style=flat-square)](https://github.com/GopeedLab/gopeed/releases)
[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://github.com/GopeedLab/gopeed/blob/main/.donate/index.md#donate)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
![](_docs/img/banner.png)
[English](/README.md) | [中文](/README_zh-CN.md)
## 介绍
Gopeed(全称 Go Speed),是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet)协议下载,并且支持全平台使用。
访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [开发文档](https://docs.gopeed.com/zh/)
## 安装
**已支持平台**
- [x] windows
- [x] macos
- [x] linux
- [x] android
- [ ] ios
- [x] web
- [x] docker
[前往下载](https://github.com/GopeedLab/gopeed/releases/latest)
> 注:macos 版本运行如果提示损坏,请在终端执行 `xattr -d com.apple.quarantine /Applications/Gopeed.app` 命令
### 命令行工具
使用`go install`安装:
```bash
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
```
### Docker
#### 直接运行
```bash
docker run -d -p 9999:9999 -v /path/to/download:/download liwei2633/gopeed
```
#### 使用 Docker Compose
```bash
docker-compose up -d
```
#### 访问服务
当 docker 容器运行时,可以通过 `http://localhost:9999` 访问 web 页面。
> 提示:在设置页面把下载路径修改为 `/download` 以便在宿主机访问下载完的文件。
## 打赏
如果觉得项目对你有帮助,请考虑[打赏](/.donate/index.md#donate)以支持这个项目的发展,非常感谢!
## 界面展示
![](_docs/img/ui-demo.png)
## 开发
本项目分为前端和后端两个部分,前端使用`flutter`,后端使用`Golang`,两边通过`http`协议进行通讯,在 unix 系统下,使用的是`unix socket`,在 windows 系统下,使用的是`tcp`协议。
> 前端代码位于`ui/flutter`目录下。
### 环境要求
1. Golang 1.19+
2. Flutter 3.7+
### 克隆项目
```bash
git clone [email protected]:monkeyWie/gopeed.git
```
### 贡献代码
请参考[贡献指南](CONTRIBUTING_zh-CN.md)
### 编译
#### 桌面端
首先需要按照[flutter desktop 官网文档](https://docs.flutter.dev/development/platform-integration/desktop)进行环境配置,然后需要准备好`cgo`环境,具体可以自行搜索。
构建命令:
- windows
```bash
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/gopeed/bind/desktop
cd ui/flutter
flutter build windows
```
- macos
```bash
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
cd ui/flutter
flutter build macos
```
- linux
```bash
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
cd ui/flutter
flutter build linux
```
#### 移动端
同样的也是需要准备好`cgo`环境,接着安装`gomobile`:
```bash
go install golang.org/x/mobile/cmd/gomobile@latest
go get golang.org/x/mobile/bind
gomobile init
```
构建命令:
- android
```bash
gomobile bind -tags nosqlite -ldflags="-w -s" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 19 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
cd ui/flutter
flutter build apk
```
#### Web 端
构建命令:
```bash
cd ui/flutter
flutter build web
cd ../../
rm -rf cmd/web/dist
cp -r ui/flutter/build/web cmd/web/dist
go build -tags nosqlite,web -ldflags="-s -w" -o bin/ github.com/GopeedLab/gopeed/cmd/web
go run cmd/web/main.go
```
## 感谢
### 贡献者
<a href="https://github.com/GopeedLab/gopeed/graphs/contributors">
<img src="https://contrib.rocks/image?repo=GopeedLab/gopeed" />
</a>
### JetBrains
[![goland](_docs/img/goland.svg)](https://www.jetbrains.com/?from=gopeed)
## 开源许可
基于 [GPLv3](LICENSE) 协议开源。
+1
View File
@@ -0,0 +1 @@
<svg height="105" viewBox="0 0 105 105" width="105" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="99.03" x2="61.54" y1="59.18" y2="90.91"><stop offset="0" stop-color="#087cfa"/><stop offset=".02" stop-color="#0d7bfa"/><stop offset=".37" stop-color="#5566f9"/><stop offset=".66" stop-color="#8a57f8"/><stop offset=".88" stop-color="#ab4ef7"/><stop offset="1" stop-color="#b74af7"/></linearGradient><linearGradient id="b" x1="37.54" x2="60.33" xlink:href="#a" y1="34.26" y2="8.33"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="17.84" x2="87.09" y1="88.36" y2="43.86"><stop offset="0" stop-color="#087cfa"/><stop offset=".1" stop-color="#1598d3"/><stop offset=".22" stop-color="#23b6aa"/><stop offset=".35" stop-color="#2dcc8b"/><stop offset=".46" stop-color="#35dd74"/><stop offset=".57" stop-color="#39e767"/><stop offset=".67" stop-color="#3bea62"/></linearGradient><path d="m89.06 41.63 11.44 25.37-19.58 33.5-12.06-29.28z" fill="url(#a)"/><path d="m68.86 71.22 12.06 29.28-30.34-10.43z" fill="#b74af7"/><path d="m72.13 31.61-6.22-27.11h-34.49l-26.92 41.18 7.79 18.92-7.79 17.27 55.75-47.43z" fill="url(#b)"/><path d="m100.5 24.88-40.25 9.56-55.75 47.43 35.96 18.63 28.4-29.28z" fill="url(#c)"/><path d="m22.5 22.5h60v60h-60z"/><g fill="#fff"><path d="m28.13 71.25h22.5v3.75h-22.5z"/><path d="m26.44 42.81v-.07a12.7 12.7 0 0 1 12.97-12.74 13.62 13.62 0 0 1 9.73 3.39l-3.43 4.14a9.13 9.13 0 0 0 -6.47-2.53c-4 0-7.11 3.5-7.11 7.7v.07c0 4.53 3.11 7.85 7.5 7.85a8.67 8.67 0 0 0 5.12-1.49v-3.56h-5.48v-4.7h10.73v10.74a16.07 16.07 0 0 1 -10.57 3.93c-7.69 0-12.99-5.41-12.99-12.73z"/><path d="m52.3 42.81v-.07a12.79 12.79 0 0 1 13.19-12.74c7.63 0 13.11 5.69 13.11 12.72v.07a12.79 12.79 0 0 1 -13.18 12.8c-7.64-.05-13.12-5.74-13.12-12.78zm20.61 0v-.07a7.51 7.51 0 0 0 -7.49-7.74 7.4 7.4 0 0 0 -7.42 7.67v.07a7.52 7.52 0 0 0 7.5 7.78 7.4 7.4 0 0 0 7.41-7.71z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+6 -3
View File
@@ -2,8 +2,9 @@ package main
import (
"fmt"
"github.com/monkeyWie/gopeed/pkg/base"
"github.com/monkeyWie/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
)
func main() {
@@ -16,7 +17,9 @@ func main() {
}
}).
Create(&base.Options{
Connections: 8,
Extra: http.OptsExtra{
Connections: 8,
},
})
if err != nil {
panic(err)
+2 -2
View File
@@ -3,8 +3,8 @@ package main
import "C"
import (
"encoding/json"
"github.com/monkeyWie/gopeed/pkg/rest"
"github.com/monkeyWie/gopeed/pkg/rest/model"
"github.com/GopeedLab/gopeed/pkg/rest"
"github.com/GopeedLab/gopeed/pkg/rest/model"
)
func main() {}
+2 -2
View File
@@ -4,8 +4,8 @@ package libgopeed
import "C"
import (
"encoding/json"
"github.com/monkeyWie/gopeed/pkg/rest"
"github.com/monkeyWie/gopeed/pkg/rest/model"
"github.com/GopeedLab/gopeed/pkg/rest"
"github.com/GopeedLab/gopeed/pkg/rest/model"
)
func Start(cfg string) (int, error) {
+16
View File
@@ -0,0 +1,16 @@
package main
import (
_ "embed"
"github.com/GopeedLab/gopeed/cmd"
"github.com/GopeedLab/gopeed/pkg/rest/model"
)
func main() {
cfg := &model.StartConfig{
Network: "tcp",
Address: "127.0.0.1:9999",
Storage: model.StorageBolt,
}
cmd.Start(cfg)
}
File renamed without changes.
+2 -3
View File
@@ -4,7 +4,6 @@ import (
"flag"
"fmt"
"os"
"path/filepath"
)
type args struct {
@@ -14,7 +13,7 @@ type args struct {
}
func parse() *args {
dir, err := filepath.Abs(filepath.Dir(os.Args[0]))
dir, err := os.Getwd()
if err != nil {
panic(err)
}
@@ -26,7 +25,7 @@ func parse() *args {
if len(t) > 0 {
args.url = t[0]
} else {
gPrintln("missing url parameter")
gPrintln("missing url parameter, for example: gopeed https://www.google.com or gopeed bt.torrent or gopeed magnet:?xt=urn:btih:...")
gPrintln("try 'gopeed -h' for more information")
os.Exit(1)
}
+17 -9
View File
@@ -2,9 +2,10 @@ package main
import (
"fmt"
"github.com/monkeyWie/gopeed/pkg/base"
"github.com/monkeyWie/gopeed/pkg/download"
"github.com/monkeyWie/gopeed/pkg/util"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/GopeedLab/gopeed/pkg/util"
"strings"
"sync"
)
@@ -12,9 +13,17 @@ import (
const progressWidth = 20
func main() {
args := parse()
//args := parse()
var dir = "./"
var connections = 8
args := &args{
url: "https://sel-kor-ping.vultr.com/vultr.com.100MB.bin",
dir: &dir,
connections: &connections,
}
var wg sync.WaitGroup
wg.Add(1)
_, err := download.Boot().
URL(args.url).
Listener(func(event *download.Event) {
@@ -39,13 +48,12 @@ func main() {
}
}).
Create(&base.Options{
Path: *args.dir,
Connections: *args.connections,
Path: *args.dir,
Extra: http.OptsExtra{Connections: *args.connections},
})
if err != nil {
panic(err)
}
wg.Add(1)
wg.Wait()
}
@@ -55,10 +63,10 @@ var (
)
func printProgress(task *download.Task, title string) {
rate := float64(task.Progress.Downloaded) / float64(task.Res.Size)
rate := float64(task.Progress.Downloaded) / float64(task.Meta.Res.Size)
completeWidth := int(progressWidth * rate)
speed := util.ByteFmt(task.Progress.Speed)
totalSize := util.ByteFmt(task.Res.Size)
totalSize := util.ByteFmt(task.Meta.Res.Size)
sb.WriteString(fmt.Sprintf("\r%s [", title))
for i := 0; i < progressWidth; i++ {
if i < completeWidth {
+40
View File
@@ -0,0 +1,40 @@
package cmd
import (
_ "embed"
"fmt"
"github.com/GopeedLab/gopeed/pkg/rest"
"github.com/GopeedLab/gopeed/pkg/rest/model"
"net/http"
"os"
"path/filepath"
)
//go:embed banner.txt
var banner string
func Start(cfg *model.StartConfig) {
fmt.Println(banner)
srv, listener, err := rest.BuildServer(cfg)
if err != nil {
panic(err)
}
downloadCfg, err := rest.Downloader.GetConfig()
if err != nil {
panic(err)
}
if downloadCfg.FirstLoad {
// Set default download dir to user download dir
userDir, err := os.UserHomeDir()
if err == nil {
downloadDir := filepath.Join(userDir, "Downloads")
downloadCfg.DownloadDir = downloadDir
rest.Downloader.PutConfig(downloadCfg)
}
}
fmt.Printf("Server start success on http://%s\n", listener.Addr().String())
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
panic(err)
}
}
+5 -18
View File
@@ -5,16 +5,11 @@ package main
import (
"embed"
"fmt"
"github.com/monkeyWie/gopeed/pkg/rest"
"github.com/monkeyWie/gopeed/pkg/rest/model"
"github.com/GopeedLab/gopeed/cmd"
"github.com/GopeedLab/gopeed/pkg/rest/model"
"io/fs"
"net/http"
)
//go:embed banner.txt
var banner string
//go:embed dist/*
var dist embed.FS
@@ -25,18 +20,10 @@ func main() {
}
cfg := &model.StartConfig{
Network: "tcp",
Address: "127.0.0.1:9999",
Storage: model.StorageMem,
Address: "0.0.0.0:9999",
Storage: model.StorageBolt,
WebEnable: true,
WebFS: sub,
}
fmt.Println(banner)
srv, listener, err := rest.BuildServer(cfg)
if err != nil {
panic(err)
}
fmt.Printf("Server start success on http://%s\n", listener.Addr().String())
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
panic(err)
}
cmd.Start(cfg)
}
+11
View File
@@ -0,0 +1,11 @@
version: '3'
services:
gopeed:
container_name: gopeed
ports:
- 9999:9999 # HTTP port (host:container)
image: liwei2633/gopeed
volumes:
- ~/Downloads:/download # replace with your prefer path
restart: unless-stopped
+2 -5
View File
@@ -1,9 +1,10 @@
module github.com/monkeyWie/gopeed
module github.com/GopeedLab/gopeed
go 1.19
require (
github.com/anacrolix/torrent v1.46.0
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0
github.com/matoous/go-nanoid/v2 v2.0.0
go.etcd.io/bbolt v1.3.6
@@ -39,7 +40,6 @@ require (
github.com/felixge/httpsnoop v1.0.1 // indirect
github.com/google/btree v1.0.1 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/gorilla/handlers v1.5.1 // indirect
github.com/gorilla/websocket v1.4.2 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/lispad/go-generics-tools v1.0.0 // indirect
@@ -66,11 +66,8 @@ require (
github.com/tidwall/btree v1.3.1 // indirect
golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838 // indirect
golang.org/x/exp v0.0.0-20220428152302-39d4317da171 // indirect
golang.org/x/mobile v0.0.0-20220928052126-fa6bcb076835 // indirect
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57 // indirect
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
)
+16 -6
View File
@@ -6,19 +6,29 @@ import (
"path/filepath"
)
type Controller interface {
Touch(name string, size int64) (file *os.File, err error)
type Controller struct {
GetConfig func(v any) (bool, error)
FileController
//ContextDialer() (proxy.Dialer, error)
}
type DefaultController struct {
type FileController interface {
Touch(name string, size int64) (file *os.File, err error)
}
func NewController() *DefaultController {
return &DefaultController{}
type DefaultFileController struct {
}
func (c *DefaultController) Touch(name string, size int64) (file *os.File, err error) {
func NewController() *Controller {
return &Controller{
FileController: &DefaultFileController{},
GetConfig: func(v any) (bool, error) {
return false, nil
},
}
}
func (c *DefaultFileController) Touch(name string, size int64) (file *os.File, err error) {
dir := filepath.Dir(name)
if err = os.MkdirAll(dir, os.ModePerm); err != nil {
return
+48 -22
View File
@@ -1,44 +1,70 @@
package fetcher
import (
"github.com/monkeyWie/gopeed/internal/controller"
"github.com/monkeyWie/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/pkg/base"
"path"
)
// 对应协议的下载支持
// Fetcher defines the interface for a download protocol.
// One fetcher for each download task
type Fetcher interface {
Setup(ctl controller.Controller) error
// 解析请求
Resolve(req *base.Request) (res *base.Resource, err error)
// 创建任务
Create(res *base.Resource, opts *base.Options) (err error)
Start() (err error)
Pause() (err error)
Continue() (err error)
Close() (err error)
// Name return the name of the protocol.
Name() string
// 获取任务各个文件下载进度
Setup(ctl *controller.Controller) error
// Resolve resource info from request
Resolve(req *base.Request) error
// Create ready to download, but not started
Create(opts *base.Options) error
Start() error
Pause() error
Continue() error
Close() error
// Meta returns the meta information of the download.
Meta() *FetcherMeta
// Progress returns the progress of the download.
Progress() Progress
// 该方法会一直阻塞,直到任务下载结束
Wait() (err error)
// Wait for the download to complete, this method will block until the download is done.
Wait() error
}
// FetcherMeta defines the meta information of a fetcher.
type FetcherMeta struct {
Req *base.Request `json:"req"`
Res *base.Resource `json:"res"`
Opts *base.Options `json:"opts"`
}
func (m *FetcherMeta) Filepath(file *base.FileInfo) string {
finalName := m.Opts.Name
if finalName == "" {
finalName = file.Name
}
return path.Join(m.Opts.Path, m.Res.RootDir, finalName)
}
// FetcherBuilder defines the interface for a fetcher builder.
type FetcherBuilder interface {
// Schemes returns the schemes supported by the fetcher.
Schemes() []string
// Build returns a new fetcher.
Build() Fetcher
// Store 存储任务
// Store fetcher
Store(fetcher Fetcher) (any, error)
// Restore 恢复任务
Restore() (v any, f func(res *base.Resource, opts *base.Options, v any) Fetcher)
// Restore fetcher
Restore() (v any, f func(meta *FetcherMeta, v any) Fetcher)
}
type DefaultFetcher struct {
Ctl controller.Controller
Ctl *controller.Controller
Meta *FetcherMeta
DoneCh chan error
}
func (f *DefaultFetcher) Setup(ctl controller.Controller) (err error) {
func (f *DefaultFetcher) Setup(ctl *controller.Controller) (err error) {
f.Ctl = ctl
f.DoneCh = make(chan error, 1)
return
@@ -48,10 +74,10 @@ func (f *DefaultFetcher) Wait() (err error) {
return <-f.DoneCh
}
// 获取任务中各个文件的已下载字节数
// Progress is a map of the progress of each file in the torrent.
type Progress []int64
// TotalDownloaded 获取任务总下载字节数
// TotalDownloaded returns the total downloaded bytes.
func (p Progress) TotalDownloaded() int64 {
total := int64(0)
for _, d := range p {
+5
View File
@@ -0,0 +1,5 @@
package bt
type config struct {
Trackers []string `json:"trackers"`
}
+99 -75
View File
@@ -1,13 +1,14 @@
package bt
import (
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/util"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/metainfo"
"github.com/anacrolix/torrent/storage"
"github.com/monkeyWie/gopeed/internal/controller"
"github.com/monkeyWie/gopeed/internal/fetcher"
"github.com/monkeyWie/gopeed/pkg/base"
"github.com/monkeyWie/gopeed/pkg/util"
"path"
"path/filepath"
"sync"
"sync/atomic"
@@ -17,11 +18,10 @@ import (
var client *torrent.Client
type Fetcher struct {
Ctl controller.Controller
ctl *controller.Controller
torrent *torrent.Torrent
res *base.Resource
opts *base.Options
meta *fetcher.FetcherMeta
ready atomic.Bool
progress fetcher.Progress
@@ -29,8 +29,15 @@ type Fetcher struct {
torrentPaths map[string]string
}
func (f *Fetcher) Setup(ctl controller.Controller) (err error) {
f.Ctl = ctl
func (f *Fetcher) Name() string {
return "bt"
}
func (f *Fetcher) Setup(ctl *controller.Controller) (err error) {
f.ctl = ctl
if f.meta == nil {
f.meta = &fetcher.FetcherMeta{}
}
var once sync.Once
once.Do(func() {
cfg := torrent.NewDefaultClientConfig()
@@ -62,52 +69,17 @@ func (f *Fetcher) Setup(ctl controller.Controller) (err error) {
return
}
func (f *Fetcher) Wait() (err error) {
for {
if f.ready.Load() {
done := true
for _, selectIndex := range f.opts.SelectFiles {
file := f.torrent.Files()[selectIndex]
if file.BytesCompleted() < file.Length() {
done = false
break
}
}
if done {
// remove unselected files
for i, file := range f.torrent.Files() {
selected := false
for _, selectIndex := range f.opts.SelectFiles {
if i == selectIndex {
selected = true
break
}
}
if !selected {
util.SafeRemove(filepath.Join(f.opts.Path, file.Path()))
}
}
break
}
}
time.Sleep(time.Millisecond * 500)
}
return nil
}
func (f *Fetcher) Resolve(req *base.Request) (res *base.Resource, err error) {
if err = f.addTorrent(req.URL, true); err != nil {
return
func (f *Fetcher) Resolve(req *base.Request) error {
if err := f.addTorrent(req.URL); err != nil {
return err
}
defer f.torrent.Drop()
res = &base.Resource{
Req: req,
Name: f.torrent.Name(),
Range: true,
Files: make([]*base.FileInfo, len(f.torrent.Files())),
Extra: map[string]any{
"infoHash": f.torrent.InfoHash().String(),
},
res := &base.Resource{
Name: f.torrent.Name(),
Range: true,
RootDir: f.torrent.Name(),
Files: make([]*base.FileInfo, len(f.torrent.Files())),
Hash: f.torrent.InfoHash().String(),
}
for i, file := range f.torrent.Files() {
res.Files[i] = &base.FileInfo{
@@ -117,37 +89,39 @@ func (f *Fetcher) Resolve(req *base.Request) (res *base.Resource, err error) {
}
res.Size += file.Length()
}
return
f.meta.Req = req
f.meta.Res = res
return nil
}
func (f *Fetcher) Create(res *base.Resource, opts *base.Options) (err error) {
f.res = res
f.opts = opts
if len(f.opts.SelectFiles) == 0 {
f.opts.SelectFiles = make([]int, 0)
func (f *Fetcher) Create(opts *base.Options) (err error) {
f.meta.Opts = opts
if len(opts.SelectFiles) == 0 {
opts.SelectFiles = make([]int, 0)
for i := range f.torrent.Files() {
f.opts.SelectFiles = append(f.opts.SelectFiles, i)
opts.SelectFiles = append(opts.SelectFiles, i)
}
}
if f.opts.Path != "" {
if extra, ok := res.Extra.(map[string]any); ok {
f.torrentPaths[extra["infoHash"].(string)] = f.opts.Path
}
if opts.Path != "" {
f.torrentPaths[f.meta.Res.Hash] = path.Join(f.meta.Opts.Path, f.meta.Res.RootDir)
}
f.progress = make(fetcher.Progress, len(f.opts.SelectFiles))
f.progress = make(fetcher.Progress, len(f.meta.Opts.SelectFiles))
f.ready.Store(false)
return nil
}
func (f *Fetcher) Start() (err error) {
if err = f.addTorrent(f.res.Req.URL, false); err != nil {
return
if !f.ready.Load() {
if err = f.addTorrent(f.meta.Req.URL); err != nil {
return
}
}
files := f.torrent.Files()
if len(f.opts.SelectFiles) == len(files) {
if len(f.meta.Opts.SelectFiles) == len(files) {
f.torrent.DownloadAll()
} else {
for _, selectIndex := range f.opts.SelectFiles {
for _, selectIndex := range f.meta.Opts.SelectFiles {
file := files[selectIndex]
file.Download()
}
@@ -170,19 +144,56 @@ func (f *Fetcher) Close() (err error) {
return nil
}
func (f *Fetcher) Wait() (err error) {
for {
if f.ready.Load() {
done := true
for _, selectIndex := range f.meta.Opts.SelectFiles {
file := f.torrent.Files()[selectIndex]
if file.BytesCompleted() < file.Length() {
done = false
break
}
}
if done {
// remove unselected files
for i, file := range f.torrent.Files() {
selected := false
for _, selectIndex := range f.meta.Opts.SelectFiles {
if i == selectIndex {
selected = true
break
}
}
if !selected {
util.SafeRemove(filepath.Join(f.meta.Opts.Path, file.Path()))
}
}
break
}
}
time.Sleep(time.Millisecond * 500)
}
return nil
}
func (f *Fetcher) Meta() *fetcher.FetcherMeta {
return f.meta
}
func (f *Fetcher) Progress() fetcher.Progress {
if !f.ready.Load() {
return f.progress
}
for i := range f.progress {
selectIndex := f.opts.SelectFiles[i]
selectIndex := f.meta.Opts.SelectFiles[i]
file := f.torrent.Files()[selectIndex]
f.progress[i] = file.BytesCompleted()
}
return f.progress
}
func (f *Fetcher) addTorrent(url string, resolve bool) (err error) {
func (f *Fetcher) addTorrent(url string) (err error) {
schema := util.ParseSchema(url)
if schema == "MAGNET" {
f.torrent, err = client.AddMagnet(url)
@@ -192,10 +203,19 @@ func (f *Fetcher) addTorrent(url string, resolve bool) (err error) {
if err != nil {
return
}
<-f.torrent.GotInfo()
if resolve {
var cfg config
exist, err := f.ctl.GetConfig(&cfg)
if err != nil {
return
}
if exist && len(cfg.Trackers) > 0 {
announceList := make([][]string, 0)
for _, tracker := range cfg.Trackers {
announceList = append(announceList, []string{tracker})
}
f.torrent.AddTrackers(announceList)
}
<-f.torrent.GotInfo()
f.ready.Store(true)
return
}
@@ -217,6 +237,10 @@ func (fb *FetcherBuilder) Store(f fetcher.Fetcher) (data any, err error) {
return nil, nil
}
func (fb *FetcherBuilder) Restore() (v any, f func(res *base.Resource, opts *base.Options, v any) fetcher.Fetcher) {
return nil, nil
func (fb *FetcherBuilder) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {
return nil, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher {
return &Fetcher{
meta: meta,
}
}
}
+39 -14
View File
@@ -1,16 +1,25 @@
package bt
import (
"github.com/monkeyWie/gopeed/internal/controller"
"github.com/monkeyWie/gopeed/internal/fetcher"
"github.com/monkeyWie/gopeed/pkg/base"
"encoding/json"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
"reflect"
"testing"
)
func TestFetcher_Resolve_Torrent(t *testing.T) {
fetcher := buildFetcher()
res, err := fetcher.Resolve(&base.Request{
doResolve(t, buildFetcher())
}
func TestFetcher_Config(t *testing.T) {
doResolve(t, buildConfigFetcher())
}
func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
err := fetcher.Resolve(&base.Request{
URL: "./testdata/ubuntu-22.04-live-server-amd64.iso.torrent",
})
if err != nil {
@@ -18,22 +27,20 @@ func TestFetcher_Resolve_Torrent(t *testing.T) {
}
want := &base.Resource{
Req: res.Req,
Name: "ubuntu-22.04-live-server-amd64.iso",
Size: 1466714112,
Range: true,
Name: "ubuntu-22.04-live-server-amd64.iso",
Size: 1466714112,
Range: true,
RootDir: "ubuntu-22.04-live-server-amd64.iso",
Files: []*base.FileInfo{
{
Name: "ubuntu-22.04-live-server-amd64.iso",
Size: 1466714112,
},
},
Extra: map[string]any{
"infoHash": "8a55cfbd5ca5d11507364765936c4f9e55b253ed",
},
Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed",
}
if !reflect.DeepEqual(want, res) {
t.Errorf("Resolve() got = %v, want %v", res, want)
if !reflect.DeepEqual(want, fetcher.Meta().Res) {
t.Errorf("Resolve() got = %v, want %v", fetcher.Meta().Res, want)
}
}
@@ -42,3 +49,21 @@ func buildFetcher() fetcher.Fetcher {
fetcher.Setup(controller.NewController())
return fetcher
}
func buildConfigFetcher() fetcher.Fetcher {
fetcher := new(FetcherBuilder).Build()
newController := controller.NewController()
mockCfg := config{
Trackers: []string{
"udp://tracker.birkenwald.de:6969/announce",
"udp://tracker.bitsearch.to:1337/announce",
}}
newController.GetConfig = func(v any) (bool, error) {
if err := json.Unmarshal([]byte(test.ToJson(mockCfg)), v); err != nil {
return false, err
}
return true, nil
}
fetcher.Setup(newController)
return fetcher
}
+5
View File
@@ -0,0 +1,5 @@
package http
type config struct {
Connections int `json:"connections"`
}
+109 -48
View File
@@ -4,9 +4,10 @@ import (
"bytes"
"context"
"fmt"
"github.com/monkeyWie/gopeed/internal/fetcher"
"github.com/monkeyWie/gopeed/pkg/base"
"github.com/monkeyWie/gopeed/pkg/util"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
"golang.org/x/sync/errgroup"
"io"
"mime"
@@ -15,9 +16,7 @@ import (
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
)
@@ -34,11 +33,26 @@ func (re *RequestError) Error() string {
return fmt.Sprintf("http request fail,code:%d", re.Code)
}
type Fetcher struct {
*fetcher.DefaultFetcher
type chunk struct {
Status base.Status
Begin int64
End int64
Downloaded int64
}
res *base.Resource
opts *base.Options
func newChunk(begin int64, end int64) *chunk {
return &chunk{
Status: base.DownloadStatusReady,
Begin: begin,
End: end,
}
}
type Fetcher struct {
ctl *controller.Controller
doneCh chan error
meta *fetcher.FetcherMeta
status base.Status
chunks []*chunk
@@ -48,22 +62,38 @@ type Fetcher struct {
pauseCh chan interface{}
}
func (f *Fetcher) Resolve(req *base.Request) (*base.Resource, error) {
func (f *Fetcher) Name() string {
return "http"
}
func (f *Fetcher) Setup(ctl *controller.Controller) (err error) {
f.ctl = ctl
f.doneCh = make(chan error, 1)
f.pauseCh = make(chan interface{}, 1)
if f.meta == nil {
f.meta = &fetcher.FetcherMeta{}
}
return
}
func (f *Fetcher) Resolve(req *base.Request) error {
if err := base.ParseReqExtra[fhttp.ReqExtra](req); err != nil {
return err
}
httpReq, err := buildRequest(nil, req)
if err != nil {
return nil, err
return err
}
client := buildClient()
// 只访问一个字节,测试资源是否支持Range请求
httpReq.Header.Set(base.HttpHeaderRange, fmt.Sprintf(base.HttpHeaderRangeFormat, 0, 0))
httpResp, err := client.Do(httpReq)
if err != nil {
return nil, err
return err
}
// 拿到响应头就关闭,不用加defer
httpResp.Body.Close()
res := &base.Resource{
Req: req,
Range: false,
Files: []*base.FileInfo{},
}
@@ -75,7 +105,7 @@ func (f *Fetcher) Resolve(req *base.Request) (*base.Resource, error) {
if contentTotal != "" {
parse, err := strconv.ParseInt(contentTotal, 10, 64)
if err != nil {
return nil, err
return err
}
res.Size = parse
}
@@ -85,12 +115,12 @@ func (f *Fetcher) Resolve(req *base.Request) (*base.Resource, error) {
if contentLength != "" {
parse, err := strconv.ParseInt(contentLength, 10, 64)
if err != nil {
return nil, err
return err
}
res.Size = parse
}
} else {
return nil, NewRequestError(httpResp.StatusCode, httpResp.Status)
return NewRequestError(httpResp.StatusCode, httpResp.Status)
}
file := &base.FileInfo{
Size: res.Size,
@@ -104,34 +134,58 @@ func (f *Fetcher) Resolve(req *base.Request) (*base.Resource, error) {
}
}
// Get file filePath by URL
if file.Name == "" && strings.Count(req.URL, "/") > 2 {
file.Name = filepath.Base(req.URL)
if file.Name == "" {
file.Name = path.Base(httpReq.URL.Path)
}
// unknown file filePath
if file.Name == "" {
if file.Name == "" || file.Name == "/" || file.Name == "." {
file.Name = "unknown"
}
res.Files = append(res.Files, file)
res.Name = file.Name
return res, nil
f.meta.Req = req
f.meta.Res = res
return nil
}
func (f *Fetcher) Create(res *base.Resource, opts *base.Options) error {
f.res = res
f.opts = opts
func (f *Fetcher) Create(opts *base.Options) error {
f.meta.Opts = opts
f.status = base.DownloadStatusReady
if err := base.ParseReqExtra[fhttp.ReqExtra](f.meta.Req); err != nil {
return err
}
if err := base.ParseOptsExtra[fhttp.OptsExtra](f.meta.Opts); err != nil {
return err
}
if opts.Extra == nil {
opts.Extra = &fhttp.OptsExtra{}
}
extra := opts.Extra.(*fhttp.OptsExtra)
if extra.Connections == 0 {
var cfg config
exist, err := f.ctl.GetConfig(&cfg)
if err != nil {
return err
}
if exist {
extra.Connections = cfg.Connections
} else {
extra.Connections = 1
}
}
return nil
}
func (f *Fetcher) Start() (err error) {
// 创建文件
name := f.filepath()
f.file, err = f.Ctl.Touch(name, f.res.Size)
f.file, err = f.ctl.Touch(name, f.meta.Res.Size)
if err != nil {
return err
}
f.status = base.DownloadStatusRunning
f.chunks = splitChunk(f.res, f.opts)
f.chunks = f.splitChunk()
f.fetch()
return
}
@@ -169,6 +223,10 @@ func (f *Fetcher) Close() (err error) {
return
}
func (f *Fetcher) Meta() *fetcher.FetcherMeta {
return f.meta
}
func (f *Fetcher) Progress() fetcher.Progress {
p := make(fetcher.Progress, 0)
if len(f.chunks) > 0 {
@@ -181,14 +239,18 @@ func (f *Fetcher) Progress() fetcher.Progress {
return p
}
func (f *Fetcher) Wait() (err error) {
return <-f.doneCh
}
func (f *Fetcher) filepath() string {
return util.Filepath(f.opts.Path, f.res.Files[0].Name, f.opts.Name)
return f.meta.Filepath(f.meta.Res.Files[0])
}
func (f *Fetcher) fetch() {
f.ctx, f.cancel = context.WithCancel(context.Background())
eg, _ := errgroup.WithContext(f.ctx)
for i := 0; i < f.opts.Connections; i++ {
for i := 0; i < len(f.chunks); i++ {
i := i
eg.Go(func() error {
return f.fetchChunk(i)
@@ -207,7 +269,7 @@ func (f *Fetcher) fetch() {
} else {
f.status = base.DownloadStatusDone
}
f.DoneCh <- err
f.doneCh <- err
}
}()
}
@@ -215,7 +277,7 @@ func (f *Fetcher) fetch() {
func (f *Fetcher) fetchChunk(index int) (err error) {
chunk := f.chunks[index]
httpReq, err := buildRequest(f.ctx, f.res.Req)
httpReq, err := buildRequest(f.ctx, f.meta.Req)
if err != nil {
return err
}
@@ -237,7 +299,7 @@ func (f *Fetcher) fetchChunk(index int) (err error) {
resp *http.Response
retry bool
)
if f.res.Range {
if f.meta.Res.Range {
httpReq.Header.Set(base.HttpHeaderRange,
fmt.Sprintf(base.HttpHeaderRangeFormat, chunk.Begin+chunk.Downloaded, chunk.End))
} else {
@@ -291,7 +353,7 @@ func (f *Fetcher) fetchChunk(index int) (err error) {
return
}
isComplete := f.res.Range && chunk.Downloaded >= chunk.End-chunk.Begin+1
isComplete := f.meta.Res.Range && chunk.Downloaded >= chunk.End-chunk.Begin+1
if f.status == base.DownloadStatusPause && !isComplete {
chunk.Status = base.DownloadStatusPause
return
@@ -301,19 +363,20 @@ func (f *Fetcher) fetchChunk(index int) (err error) {
return
}
func splitChunk(res *base.Resource, opts *base.Options) (chunks []*chunk) {
if res.Range {
func (f *Fetcher) splitChunk() (chunks []*chunk) {
if f.meta.Res.Range {
connections := f.meta.Opts.Extra.(*fhttp.OptsExtra).Connections
// 每个连接平均需要下载的分块大小
chunkSize := res.Size / int64(opts.Connections)
chunks = make([]*chunk, opts.Connections)
for i := 0; i < opts.Connections; i++ {
chunkSize := f.meta.Res.Size / int64(connections)
chunks = make([]*chunk, connections)
for i := 0; i < connections; i++ {
var (
begin = chunkSize * int64(i)
end int64
)
if i == opts.Connections-1 {
if i == connections-1 {
// 最后一个分块需要保证把文件下载完
end = res.Size - 1
end = f.meta.Res.Size - 1
} else {
end = begin + chunkSize - 1
}
@@ -351,7 +414,7 @@ func buildRequest(ctx context.Context, req *base.Request) (httpReq *http.Request
if req.Extra == nil {
method = http.MethodGet
} else {
extra := req.Extra.(extra)
extra := req.Extra.(*fhttp.ReqExtra)
if extra.Method != "" {
method = extra.Method
} else {
@@ -393,10 +456,7 @@ func (fb *FetcherBuilder) Schemes() []string {
}
func (fb *FetcherBuilder) Build() fetcher.Fetcher {
return &Fetcher{
DefaultFetcher: new(fetcher.DefaultFetcher),
pauseCh: make(chan interface{}),
}
return &Fetcher{}
}
func (fb *FetcherBuilder) Store(f fetcher.Fetcher) (data any, err error) {
@@ -406,16 +466,17 @@ func (fb *FetcherBuilder) Store(f fetcher.Fetcher) (data any, err error) {
}, nil
}
func (fb *FetcherBuilder) Restore() (v any, f func(res *base.Resource, opts *base.Options, v any) fetcher.Fetcher) {
return &fetcherData{}, func(res *base.Resource, opts *base.Options, v any) fetcher.Fetcher {
func (fb *FetcherBuilder) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {
return &fetcherData{}, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher {
fd := v.(*fetcherData)
fb := &FetcherBuilder{}
fetcher := fb.Build().(*Fetcher)
fetcher.res = res
fetcher.opts = opts
fetcher.meta = meta
fetcher.status = base.DownloadStatusPause
base.ParseReqExtra[fhttp.ReqExtra](fetcher.meta.Req)
base.ParseOptsExtra[fhttp.OptsExtra](fetcher.meta.Opts)
if len(fd.Chunks) == 0 {
fetcher.chunks = splitChunk(res, opts)
fetcher.chunks = fetcher.splitChunk()
} else {
fetcher.chunks = fd.Chunks
}
+68 -24
View File
@@ -1,11 +1,13 @@
package http
import (
"encoding/json"
"fmt"
"github.com/monkeyWie/gopeed/internal/controller"
"github.com/monkeyWie/gopeed/internal/fetcher"
"github.com/monkeyWie/gopeed/internal/test"
"github.com/monkeyWie/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
"net"
"reflect"
"testing"
@@ -41,15 +43,14 @@ func testResolve(startTestServer func() net.Listener, want *base.Resource, t *te
listener := startTestServer()
defer listener.Close()
fetcher := buildFetcher()
res, err := fetcher.Resolve(&base.Request{
err := fetcher.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
})
if err != nil {
t.Fatal(err)
}
res.Req = nil
if !reflect.DeepEqual(want, res) {
t.Errorf("Resolve() got = %v, want %v", res, want)
if !reflect.DeepEqual(want, fetcher.meta.Res) {
t.Errorf("Resolve() got = %v, want %v", fetcher.meta.Res, want)
}
}
@@ -78,6 +79,7 @@ func TestFetcher_DownloadChunked(t *testing.T) {
defer listener.Close()
// chunked编码下载
downloadNormal(listener, 1, t)
downloadNormal(listener, 2, t)
}
func TestFetcher_DownloadPost(t *testing.T) {
@@ -111,28 +113,54 @@ func TestFetcher_DownloadResume(t *testing.T) {
downloadResume(listener, 16, t)
}
func downloadReady(listener net.Listener, connections int, t *testing.T) (fetcher fetcher.Fetcher, res *base.Resource, opts *base.Options) {
fetcher = buildFetcher()
res, err := fetcher.Resolve(&base.Request{
func TestFetcher_Config(t *testing.T) {
fetcher := doDownloadReady(buildConfigFetcher(), test.StartTestFileServer(), 0, t)
err := fetcher.Start()
if err != nil {
t.Fatal(err)
}
err = fetcher.Wait()
if err != nil {
t.Fatal(err)
}
want := test.FileMd5(test.BuildFile)
got := test.FileMd5(test.DownloadFile)
if want != got {
t.Errorf("Download() got = %v, want %v", got, want)
}
}
func downloadReady(listener net.Listener, connections int, t *testing.T) fetcher.Fetcher {
return doDownloadReady(buildFetcher(), listener, connections, t)
}
func doDownloadReady(f fetcher.Fetcher, listener net.Listener, connections int, t *testing.T) fetcher.Fetcher {
err := f.Resolve(&base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
})
if err != nil {
t.Fatal(err)
}
opts = &base.Options{
Name: test.DownloadName,
Path: test.Dir,
Connections: connections,
var extra any = nil
if connections > 0 {
extra = http.OptsExtra{
Connections: connections,
}
}
err = fetcher.Create(res, opts)
opts := &base.Options{
Name: test.DownloadName,
Path: test.Dir,
Extra: extra,
}
err = f.Create(opts)
if err != nil {
t.Fatal(err)
}
return
return f
}
func downloadNormal(listener net.Listener, connections int, t *testing.T) {
fetcher, _, _ := downloadReady(listener, connections, t)
fetcher := downloadReady(listener, connections, t)
err := fetcher.Start()
if err != nil {
t.Fatal(err)
@@ -149,8 +177,8 @@ func downloadNormal(listener net.Listener, connections int, t *testing.T) {
}
func downloadPost(listener net.Listener, connections int, t *testing.T) {
fetcher, _, _ := downloadReady(listener, connections, t)
fetcher.(*Fetcher).res.Req.Extra = extra{
fetcher := downloadReady(listener, connections, t)
fetcher.Meta().Req.Extra = &http.ReqExtra{
Method: "POST",
Header: map[string]string{
"Authorization": "Bearer 123456",
@@ -173,7 +201,7 @@ func downloadPost(listener net.Listener, connections int, t *testing.T) {
}
func downloadContinue(listener net.Listener, connections int, t *testing.T) {
fetcher, _, _ := downloadReady(listener, connections, t)
fetcher := downloadReady(listener, connections, t)
err := fetcher.Start()
if err != nil {
t.Fatal(err)
@@ -198,7 +226,7 @@ func downloadContinue(listener net.Listener, connections int, t *testing.T) {
}
func downloadError(listener net.Listener, connections int, t *testing.T) {
fetcher, _, _ := downloadReady(listener, connections, t)
fetcher := downloadReady(listener, connections, t)
err := fetcher.Start()
if err != nil {
t.Fatal(err)
@@ -210,7 +238,7 @@ func downloadError(listener net.Listener, connections int, t *testing.T) {
}
func downloadResume(listener net.Listener, connections int, t *testing.T) {
fetcher, res, opts := downloadReady(listener, connections, t)
fetcher := downloadReady(listener, connections, t)
err := fetcher.Start()
if err != nil {
t.Fatal(err)
@@ -226,7 +254,7 @@ func downloadResume(listener net.Listener, connections int, t *testing.T) {
fetcher.Pause()
_, f := fb.Restore()
f(res, opts, data)
f(fetcher.Meta(), data)
if err != nil {
t.Fatal(err)
}
@@ -249,3 +277,19 @@ func buildFetcher() *Fetcher {
fetcher.Setup(controller.NewController())
return fetcher.(*Fetcher)
}
func buildConfigFetcher() fetcher.Fetcher {
fetcher := new(FetcherBuilder).Build()
newController := controller.NewController()
mockCfg := config{
Connections: 16,
}
newController.GetConfig = func(v any) (bool, error) {
if err := json.Unmarshal([]byte(test.ToJson(mockCfg)), v); err != nil {
return false, err
}
return true, nil
}
fetcher.Setup(newController)
return fetcher
}
-24
View File
@@ -1,24 +0,0 @@
package http
import "github.com/monkeyWie/gopeed/pkg/base"
type chunk struct {
Status base.Status
Begin int64
End int64
Downloaded int64
}
func newChunk(begin int64, end int64) *chunk {
return &chunk{
Status: base.DownloadStatusReady,
Begin: begin,
End: end,
}
}
type extra struct {
Method string
Header map[string]string
Body string
}
+4
View File
@@ -49,3 +49,7 @@ func ToJson(v interface{}) string {
buf, _ := json.Marshal(v)
return string(buf)
}
func JsonEqual(v1 any, v2 any) bool {
return ToJson(v1) == ToJson(v2)
}
+2 -1
View File
@@ -3,9 +3,10 @@ package base
type Status string
const (
DownloadStatusReady Status = "ready"
DownloadStatusReady Status = "ready" // task create but not start
DownloadStatusRunning Status = "running"
DownloadStatusPause Status = "pause"
DownloadStatusWait Status = "wait" // task is wait for running
DownloadStatusError Status = "error"
DownloadStatusDone Status = "done"
)
+8
View File
@@ -0,0 +1,8 @@
package base
import "errors"
var (
NotFound = errors.New("not found")
BadParams = errors.New("bad params")
)
+50 -21
View File
@@ -1,26 +1,25 @@
package base
// 下载请求
import (
"github.com/GopeedLab/gopeed/pkg/util"
)
// Request download request
type Request struct {
// 下载链接
URL string `json:"url"`
// 附加信息
Extra interface{} `json:"extra"`
URL string `json:"url"`
Extra any `json:"extra"`
}
// 资源信息
// Resource download resource
type Resource struct {
Req *Request `json:"req"`
// 资源名称
Name string `json:"name"`
// 资源总大小
Size int64 `json:"size"`
// 是否支持断点下载
Range bool `json:"range"`
// 资源所包含的文件列表
Size int64 `json:"size"`
// is support range download
Range bool `json:"range"`
RootDir string `json:"rootDir"`
// file list
Files []*FileInfo `json:"files"`
// 附加信息
Extra interface{} `json:"extra"`
Hash string `json:"hash"`
}
type FileInfo struct {
@@ -29,14 +28,44 @@ type FileInfo struct {
Size int64 `json:"size"`
}
// 下载选项
// Options for download
type Options struct {
// 保存文件名
// Download file name
Name string `json:"name"`
// 保存目录
// Download file path
Path string `json:"path"`
// 并发连接数
Connections int `json:"connections"`
// 选择下载的文件下标列表
// Select file indexes to download
SelectFiles []int `json:"selectFiles"`
// Extra info for specific fetcher
Extra any `json:"extra"`
}
func ParseReqExtra[E any](req *Request) error {
if req.Extra == nil {
return nil
}
if _, ok := req.Extra.(*E); ok {
return nil
}
var t E
if err := util.MapToStruct(req.Extra, &t); err != nil {
return err
}
req.Extra = &t
return nil
}
func ParseOptsExtra[E any](opts *Options) error {
if opts.Extra == nil {
return nil
}
if _, ok := opts.Extra.(*E); ok {
return nil
}
var t E
if err := util.MapToStruct(opts.Extra, &t); err != nil {
return err
}
opts.Extra = &t
return nil
}
+322 -130
View File
@@ -2,12 +2,15 @@ package download
import (
"errors"
"github.com/monkeyWie/gopeed/internal/controller"
"github.com/monkeyWie/gopeed/internal/fetcher"
"github.com/monkeyWie/gopeed/internal/protocol/bt"
"github.com/monkeyWie/gopeed/internal/protocol/http"
"github.com/monkeyWie/gopeed/pkg/base"
"github.com/monkeyWie/gopeed/pkg/util"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/util"
gonanoid "github.com/matoous/go-nanoid/v2"
"math"
"os"
"path"
"sort"
"strings"
"sync"
"sync/atomic"
@@ -15,8 +18,11 @@ import (
)
const (
bucketTask = "task"
bucketSave = "save"
// task info bucket
bucketTask = "task"
// task download data bucket
bucketSave = "save"
// downloader config bucket
bucketConfig = "config"
)
@@ -32,43 +38,15 @@ type Progress struct {
}
type Downloader struct {
controller *controller.DefaultController
cfg *DownloaderConfig
fetchBuilders map[string]fetcher.FetcherBuilder
fetcherMap map[string]fetcher.Fetcher
storage Storage
tasks []*Task
listener Listener
refreshInterval int
lock *sync.Mutex
closed atomic.Bool
}
type DownloaderConfig struct {
Controller *controller.DefaultController
FetchBuilders []fetcher.FetcherBuilder
Storage Storage
// RefreshInterval time duration to refresh task progress(ms)
RefreshInterval int
}
func (cfg *DownloaderConfig) Init() *DownloaderConfig {
if cfg.Controller == nil {
cfg.Controller = controller.NewController()
}
if len(cfg.FetchBuilders) == 0 {
cfg.FetchBuilders = []fetcher.FetcherBuilder{
new(http.FetcherBuilder),
new(bt.FetcherBuilder),
}
}
if cfg.Storage == nil {
cfg.Storage = NewMemStorage()
}
if cfg.RefreshInterval == 0 {
cfg.RefreshInterval = 1000
}
return cfg
lock *sync.Mutex
closed atomic.Bool
}
func NewDownloader(cfg *DownloaderConfig) *Downloader {
@@ -78,11 +56,13 @@ func NewDownloader(cfg *DownloaderConfig) *Downloader {
cfg.Init()
d := &Downloader{
controller: cfg.Controller,
fetchBuilders: make(map[string]fetcher.FetcherBuilder),
refreshInterval: cfg.RefreshInterval,
storage: cfg.Storage,
lock: &sync.Mutex{},
cfg: cfg,
fetchBuilders: make(map[string]fetcher.FetcherBuilder),
fetcherMap: make(map[string]fetcher.Fetcher),
storage: cfg.Storage,
lock: &sync.Mutex{},
}
for _, f := range cfg.FetchBuilders {
for _, p := range f.Schemes() {
@@ -97,6 +77,21 @@ func (d *Downloader) Setup() error {
if err := d.storage.Setup([]string{bucketTask, bucketSave, bucketConfig}); err != nil {
return err
}
// load config from storage
var cfg DownloaderStoreConfig
exist, err := d.storage.Get(bucketConfig, "config", &cfg)
if err != nil {
return err
}
if exist {
d.cfg.DownloaderStoreConfig = &cfg
} else {
d.cfg.DownloaderStoreConfig = &DownloaderStoreConfig{
FirstLoad: true,
}
}
// init default config
d.cfg.DownloaderStoreConfig.Init()
// load tasks from storage
var tasks []*Task
if err := d.storage.List(bucketTask, &tasks); err != nil {
@@ -105,26 +100,35 @@ func (d *Downloader) Setup() error {
if tasks == nil {
tasks = make([]*Task, 0)
} else {
for _, task := range tasks {
for i := len(tasks) - 1; i >= 0; i-- {
task := tasks[i]
// Remove broken tasks
if task.Meta == nil {
tasks = append(tasks[:i], tasks[i+1:]...)
continue
}
initTask(task)
if task.Status != base.DownloadStatusDone && task.Status != base.DownloadStatusError {
task.Status = base.DownloadStatusPause
}
}
// sort by create time
sort.Slice(tasks, func(i, j int) bool {
return tasks[i].CreatedAt.Before(tasks[j].CreatedAt)
})
}
d.tasks = tasks
// 每秒统计一次下载速度
// 每个tick统计一次下载速度
go func() {
for !d.closed.Load() {
if len(d.tasks) > 0 {
for _, task := range d.tasks {
func() {
task.lock.Lock()
defer task.lock.Unlock()
if task.Status == base.DownloadStatusDone ||
task.Status == base.DownloadStatusError ||
task.Status == base.DownloadStatusPause {
if task.Status != base.DownloadStatusRunning {
return
}
// check if task is deleted
@@ -134,7 +138,7 @@ func (d *Downloader) Setup() error {
current := task.fetcher.Progress().TotalDownloaded()
task.Progress.Used = task.timer.Used()
task.Progress.Speed = current - task.Progress.Downloaded
task.Progress.Speed = task.calcSpeed(current-task.Progress.Downloaded, float64(d.cfg.RefreshInterval)/1000)
task.Progress.Downloaded = current
d.emit(EventKeyProgress, task)
@@ -152,98 +156,144 @@ func (d *Downloader) Setup() error {
}()
}
}
time.Sleep(time.Millisecond * time.Duration(d.refreshInterval))
time.Sleep(time.Millisecond * time.Duration(d.cfg.RefreshInterval))
}
}()
return nil
}
func (d *Downloader) buildFetcher(url string) (fetcher.FetcherBuilder, fetcher.Fetcher, error) {
func (d *Downloader) parseFb(url string) (fetcher.FetcherBuilder, error) {
schema := util.ParseSchema(url)
fetchBuilder, ok := d.fetchBuilders[schema]
if !ok {
fetchBuilder, ok = d.fetchBuilders[util.FileSchema]
}
if ok {
fetcher := fetchBuilder.Build()
fetcher.Setup(d.controller)
return fetchBuilder, fetcher, nil
return fetchBuilder, nil
}
return nil, nil, errors.New("unsupported protocol")
return nil, errors.New("unsupported protocol")
}
func (d *Downloader) Resolve(req *base.Request) (*base.Resource, error) {
_, fetcher, err := d.buildFetcher(req.URL)
if err != nil {
return nil, err
func (d *Downloader) setupFetcher(fetcher fetcher.Fetcher) {
ctl := controller.NewController()
ctl.GetConfig = func(v any) (bool, error) {
return d.getProtocolConfig(fetcher.Name(), v)
}
res, err := fetcher.Resolve(req)
if err != nil {
return nil, err
}
return res, nil
fetcher.Setup(ctl)
}
func (d *Downloader) Create(res *base.Resource, opts *base.Options) (taskId string, err error) {
func (d *Downloader) Resolve(req *base.Request) (rr *ResolveResult, err error) {
fb, err := d.parseFb(req.URL)
if err != nil {
return
}
fetcher := fb.Build()
d.setupFetcher(fetcher)
err = fetcher.Resolve(req)
if err != nil {
return
}
rrId, err := gonanoid.New()
if err != nil {
return
}
d.fetcherMap[rrId] = fetcher
rr = &ResolveResult{
ID: rrId,
Res: fetcher.Meta().Res,
}
return
}
func (d *Downloader) CreateDirect(req *base.Request, opts *base.Options) (taskId string, err error) {
rr, err := d.Resolve(req)
if err != nil {
return
}
return d.Create(rr.ID, opts)
}
func (d *Downloader) Create(rrId string, opts *base.Options) (taskId string, err error) {
if opts == nil {
opts = &base.Options{}
}
if !res.Range || opts.Connections < 1 {
opts.Connections = 1
fetcher, ok := d.fetcherMap[rrId]
if !ok {
return "", errors.New("invalid resource id")
}
defer delete(d.fetcherMap, rrId)
meta := fetcher.Meta()
meta.Opts = opts
res := meta.Res
if len(opts.SelectFiles) == 0 {
opts.SelectFiles = make([]int, 0)
for i := range res.Files {
opts.SelectFiles = append(opts.SelectFiles, i)
}
}
if opts.Path == "" {
storeConfig, err := d.GetConfig()
if err != nil {
return "", err
}
opts.Path = storeConfig.DownloadDir
}
fetcherBuilder, fetcher, err := d.buildFetcher(res.Req.URL)
fb, err := d.parseFb(fetcher.Meta().Req.URL)
if err != nil {
return
}
err = fetcher.Create(res, opts)
// check if the download file is duplicated and rename it automatically.
files := res.Files
if res.RootDir != "" {
fullDirPath := path.Join(opts.Path, res.RootDir)
newName, err := util.CheckDuplicateAndRename(fullDirPath)
if err != nil {
return "", err
}
res.RootDir = newName
} else if len(files) == 1 {
fullPath := meta.Filepath(files[0])
newName, err := util.CheckDuplicateAndRename(fullPath)
if err != nil {
return "", err
}
opts.Name = newName
}
err = fetcher.Create(opts)
if err != nil {
return
}
task := NewTask()
task.fetcherBuilder = fetcherBuilder
task.fetcherBuilder = fb
task.fetcher = fetcher
task.Res = res
task.Opts = opts
task.Meta = fetcher.Meta()
task.Progress = &Progress{}
task.Status = base.DownloadStatusRunning
task.Status = base.DownloadStatusReady
// calculate select files size
for _, selectIndex := range opts.SelectFiles {
task.Size += res.Files[selectIndex].Size
}
initTask(task)
task.timer.Start()
err = func() error {
d.lock.Lock()
defer d.lock.Unlock()
if err := d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {
return err
}
d.tasks = append(d.tasks, task)
return nil
return d.storage.Put(bucketTask, task.ID, task.clone())
}()
if err != nil {
return
}
d.emit(EventKeyStart, task)
taskId = task.ID
go func() {
err := fetcher.Start()
if err != nil {
d.emit(EventKeyError, task, err)
return
d.tryRunning(func(remain int) {
if remain > 0 {
err = d.start(task)
}
d.watch(task)
}()
})
return
}
@@ -252,16 +302,10 @@ func (d *Downloader) Pause(id string) (err error) {
if task == nil {
return
}
task.lock.Lock()
defer task.lock.Unlock()
if task.Status == base.DownloadStatusRunning {
task.Status = base.DownloadStatusPause
task.timer.Pause()
task.fetcher.Pause()
d.storage.Put(bucketTask, task.ID, task.clone())
d.emit(EventKeyPause, task)
if err = d.doPause(task, base.DownloadStatusPause); err != nil {
return err
}
return
return d.notifyRunning()
}
func (d *Downloader) Continue(id string) (err error) {
@@ -269,21 +313,57 @@ func (d *Downloader) Continue(id string) (err error) {
if task == nil {
return
}
task.lock.Lock()
defer task.lock.Unlock()
if task.Status == base.DownloadStatusPause || task.Status == base.DownloadStatusError {
task.Status = base.DownloadStatusRunning
err = d.restoreFetcher(task)
d.tryRunning(func(remain int) {
if remain == 0 {
// no more task can be running, need to doPause a running task
for _, t := range d.tasks {
if t.Status == base.DownloadStatusRunning {
err = d.doPause(t, base.DownloadStatusWait)
break
}
}
}
if err != nil {
return
}
task.timer.Start()
err = task.fetcher.Continue()
if err != nil {
})
if err != nil {
return
}
return d.doContinue(task)
}
func (d *Downloader) PauseAll() (err error) {
d.lock.Lock()
defer d.lock.Unlock()
for _, task := range d.tasks {
if task.Status == base.DownloadStatusRunning {
if err = d.doPause(task, base.DownloadStatusPause); err != nil {
return
}
}
}
return
}
func (d *Downloader) ContinueAll() (err error) {
d.lock.Lock()
defer d.lock.Unlock()
runningCount := 0
continueTasks := make([]*Task, 0)
for _, task := range d.tasks {
if task.Status == base.DownloadStatusRunning {
runningCount++
} else if task.Status != base.DownloadStatusDone {
continueTasks = append(continueTasks, task)
}
}
// calculate how many tasks can be continued, can't exceed maxRunning
continueCount := int(math.Min(float64(d.cfg.MaxRunning-runningCount), float64(len(continueTasks))))
for i := 0; i < continueCount; i++ {
if err = d.doContinue(continueTasks[i]); err != nil {
return
}
d.storage.Put(bucketTask, task.ID, task.clone())
d.emit(EventKeyContinue, task)
}
return
}
@@ -318,13 +398,24 @@ func (d *Downloader) Delete(id string, force bool) (err error) {
return
}
if force {
names := make([]string, 0)
for _, file := range task.Res.Files {
names = append(names, util.Filepath(file.Path, file.Name, task.Opts.Name))
if task.Meta.Res.RootDir != "" {
if err = os.RemoveAll(path.Join(task.Meta.Opts.Path, task.Meta.Res.RootDir)); err != nil {
return
}
} else {
for _, file := range task.Meta.Res.Files {
if err = util.SafeRemove(task.Meta.Filepath(file)); err != nil {
return err
}
}
}
util.SafeRemoveAll(task.Opts.Path, names)
}
d.emit(EventKeyDelete, task)
if task.Status == base.DownloadStatusRunning {
if err = d.doNotifyRunning(); err != nil {
return err
}
}
task = nil
return
}
@@ -334,7 +425,7 @@ func (d *Downloader) Close() error {
defer d.lock.Unlock()
d.closed.Store(true)
for _, task := range d.tasks {
d.Pause(task.ID)
d.doPause(task, base.DownloadStatusPause)
}
return d.storage.Close()
}
@@ -380,14 +471,30 @@ func (d *Downloader) GetTasks() []*Task {
return d.tasks
}
func (d *Downloader) GetConfig(v any) (bool, error) {
return d.storage.Get(bucketConfig, "config", v)
func (d *Downloader) GetConfig() (*DownloaderStoreConfig, error) {
return d.cfg.DownloaderStoreConfig, nil
}
func (d *Downloader) PutConfig(v any) error {
func (d *Downloader) PutConfig(v *DownloaderStoreConfig) error {
d.cfg.DownloaderStoreConfig = v
return d.storage.Put(bucketConfig, "config", v)
}
func (d *Downloader) getProtocolConfig(name string, v any) (bool, error) {
cfg, err := d.GetConfig()
if err != nil {
return false, err
}
if cfg.ProtocolConfig == nil || cfg.ProtocolConfig[name] == nil {
return false, nil
}
if err := util.MapToStruct(cfg.ProtocolConfig[name], v); err != nil {
return false, err
}
return true, nil
}
// wait task done
func (d *Downloader) watch(task *Task) {
err := task.fetcher.Wait()
if err != nil {
@@ -397,7 +504,7 @@ func (d *Downloader) watch(task *Task) {
task.Progress.Used = task.timer.Used()
if task.Size == 0 {
task.Size = task.fetcher.Progress().TotalDownloaded()
task.Res.Size = task.Size
task.Meta.Res.Size = task.Size
}
used := task.Progress.Used / int64(time.Second)
if used == 0 {
@@ -410,42 +517,127 @@ func (d *Downloader) watch(task *Task) {
d.emit(EventKeyDone, task)
}
d.emit(EventKeyFinally, task, err)
d.notifyRunning()
}
func (d *Downloader) restoreFetcher(task *Task) error {
if task.fetcher == nil {
fetcherBuilder, _, err := d.buildFetcher(task.Res.Req.URL)
fb, err := d.parseFb(task.Meta.Req.URL)
if err != nil {
return err
}
task.fetcherBuilder = fetcherBuilder
task.fetcherBuilder = fb
err = func() error {
v, f := fetcherBuilder.Restore()
v, f := fb.Restore()
if v != nil {
err := d.storage.Pop(bucketSave, task.ID, v)
if err != nil {
return err
}
task.fetcher = f(task.Res, task.Opts, v)
}
task.fetcher = f(task.Meta, v)
return nil
}()
if err != nil {
// TODO log
}
if task.fetcher == nil {
task.fetcher = fetcherBuilder.Build()
task.fetcher.Create(task.Res, task.Opts)
task.fetcher = fb.Build()
}
task.fetcher.Setup(d.controller)
go d.watch(task)
d.setupFetcher(task.fetcher)
task.fetcher.Create(task.Meta.Opts)
}
return nil
}
func (d *Downloader) tryRunning(cb func(remain int)) {
d.lock.Lock()
defer d.lock.Unlock()
runningCount := 0
for _, task := range d.tasks {
if task.Status == base.DownloadStatusRunning {
runningCount++
}
}
cb(d.cfg.MaxRunning - runningCount)
}
func (d *Downloader) notifyRunning() error {
d.lock.Lock()
defer d.lock.Unlock()
return d.doNotifyRunning()
}
func (d *Downloader) doNotifyRunning() error {
for _, task := range d.tasks {
if task.Status == base.DownloadStatusReady || task.Status == base.DownloadStatusWait {
return d.doContinue(task)
}
}
return nil
}
func (d *Downloader) start(task *Task) error {
var event EventKey
if task.Status == base.DownloadStatusReady {
event = EventKeyStart
} else {
event = EventKeyContinue
}
task.Status = base.DownloadStatusRunning
task.Progress.Speed = 0
task.timer.Start()
if err := d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {
return err
}
var err error
if event == EventKeyStart {
err = task.fetcher.Start()
}
if event == EventKeyContinue {
err = task.fetcher.Continue()
}
if err != nil {
return err
}
go d.watch(task)
d.emit(event, task)
return nil
}
func (d *Downloader) doPause(task *Task, status base.Status) (err error) {
task.lock.Lock()
defer task.lock.Unlock()
if task.Status == base.DownloadStatusRunning {
task.Status = status
task.timer.Pause()
task.fetcher.Pause()
d.storage.Put(bucketTask, task.ID, task.clone())
d.emit(EventKeyPause, task)
}
return
}
func (d *Downloader) doContinue(task *Task) (err error) {
task.lock.Lock()
defer task.lock.Unlock()
if task.Status != base.DownloadStatusRunning && task.Status != base.DownloadStatusDone {
err = d.restoreFetcher(task)
if err != nil {
return
}
err = d.start(task)
}
return
}
func initTask(task *Task) {
task.timer = &util.Timer{}
task.timer = util.NewTimer(task.Progress.Used)
task.lock = &sync.Mutex{}
task.speedArr = make([]int64, 0)
}
var defaultDownloader = NewDownloader(nil)
@@ -466,7 +658,7 @@ func (b *boot) Extra(extra interface{}) *boot {
return b
}
func (b *boot) Resolve() (*base.Resource, error) {
func (b *boot) Resolve() (*ResolveResult, error) {
return defaultDownloader.Resolve(&base.Request{
URL: b.url,
Extra: b.extra,
@@ -479,12 +671,12 @@ func (b *boot) Listener(listener Listener) *boot {
}
func (b *boot) Create(opts *base.Options) (string, error) {
res, err := b.Resolve()
rr, err := b.Resolve()
if err != nil {
return "", err
}
defaultDownloader.Listener(b.listener)
return defaultDownloader.Create(res, opts)
return defaultDownloader.Create(rr.ID, opts)
}
func Boot() *boot {
+71 -16
View File
@@ -1,8 +1,9 @@
package download
import (
"github.com/monkeyWie/gopeed/internal/test"
"github.com/monkeyWie/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
"reflect"
"sync"
"testing"
@@ -21,13 +22,12 @@ func TestDownloader_Resolve(t *testing.T) {
req := &base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
res, err := downloader.Resolve(req)
rr, err := downloader.Resolve(req)
if err != nil {
t.Fatal(err)
}
want := &base.Resource{
Name: test.BuildName,
Req: req,
Size: test.BuildSize,
Range: true,
Files: []*base.FileInfo{
@@ -38,8 +38,8 @@ func TestDownloader_Resolve(t *testing.T) {
},
},
}
if !reflect.DeepEqual(want, res) {
t.Errorf("Resolve() got = %v, want %v", res, want)
if !reflect.DeepEqual(want, rr.Res) {
t.Errorf("Resolve() got = %v, want %v", rr.Res, want)
}
}
@@ -55,7 +55,7 @@ func TestDownloader_Create(t *testing.T) {
req := &base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
res, err := downloader.Resolve(req)
rr, err := downloader.Resolve(req)
if err != nil {
t.Fatal(err)
}
@@ -67,10 +67,12 @@ func TestDownloader_Create(t *testing.T) {
wg.Done()
}
})
_, err = downloader.Create(res, &base.Options{
Path: test.Dir,
Name: test.DownloadName,
Connections: 4,
_, err = downloader.Create(rr.ID, &base.Options{
Path: test.Dir,
Name: test.DownloadName,
Extra: http.OptsExtra{
Connections: 4,
},
})
if err != nil {
t.Fatal(err)
@@ -97,15 +99,17 @@ func TestDownloader_StoreAndRestore(t *testing.T) {
req := &base.Request{
URL: "http://" + listener.Addr().String() + "/" + test.BuildName,
}
res, err := downloader.Resolve(req)
rr, err := downloader.Resolve(req)
if err != nil {
t.Fatal(err)
}
id, err := downloader.Create(res, &base.Options{
Path: test.Dir,
Name: test.DownloadName,
Connections: 4,
id, err := downloader.Create(rr.ID, &base.Options{
Path: test.Dir,
Name: test.DownloadName,
Extra: http.OptsExtra{
Connections: 4,
},
})
if err != nil {
t.Fatal(err)
@@ -148,3 +152,54 @@ func TestDownloader_StoreAndRestore(t *testing.T) {
downloader.Clear()
}
func TestDownloader_Protocol_Config(t *testing.T) {
downloader := NewDownloader(nil)
if err := downloader.Setup(); err != nil {
t.Fatal(err)
}
defer downloader.Clear()
var httpCfg map[string]any
exits, err := downloader.getProtocolConfig("http", &httpCfg)
if err != nil {
t.Fatal(err)
}
if exits {
t.Errorf("getProtocolConfig() got = %v, want %v", exits, false)
}
storeCfg := &DownloaderStoreConfig{
DownloadDir: "./downloads",
ProtocolConfig: map[string]any{
"http": map[string]any{
"connections": 4,
},
"bt": map[string]any{
"trackerSubscribeUrls": []string{
"https://raw.githubusercontent.com/XIU2/TrackersListCollection/master/best.txt",
},
"trackers": []string{
"udp://tracker.coppersurfer.tk:6969/announce",
"udp://tracker.leechers-paradise.org:6969/announce",
},
},
},
Extra: map[string]any{
"theme": "dark",
},
}
if err = downloader.PutConfig(storeCfg); err != nil {
t.Fatal(err)
}
newStoreCfg, err := downloader.GetConfig()
if err != nil {
t.Fatal(err)
}
if !test.JsonEqual(storeCfg, newStoreCfg) {
t.Errorf("GetConfig() got = %v, want %v", test.ToJson(storeCfg), test.ToJson(newStoreCfg))
}
}
+114
View File
@@ -0,0 +1,114 @@
package download
import (
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/protocol/bt"
"github.com/GopeedLab/gopeed/internal/protocol/http"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/util"
gonanoid "github.com/matoous/go-nanoid/v2"
"sync"
"time"
)
type ResolveResult struct {
ID string `json:"id"`
Res *base.Resource `json:"res"`
}
type Task struct {
ID string `json:"id"`
Meta *fetcher.FetcherMeta `json:"meta"`
Status base.Status `json:"status"`
Progress *Progress `json:"progress"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"createdAt"`
fetcherBuilder fetcher.FetcherBuilder
fetcher fetcher.Fetcher
timer *util.Timer
lock *sync.Mutex
speedArr []int64
}
func NewTask() *Task {
id, err := gonanoid.New()
if err != nil {
panic(err)
}
return &Task{
ID: id,
Status: base.DownloadStatusReady,
CreatedAt: time.Now(),
}
}
func (t *Task) clone() *Task {
return &Task{
ID: t.ID,
Meta: t.Meta,
Status: t.Status,
Progress: t.Progress,
Size: t.Size,
CreatedAt: t.CreatedAt,
}
}
func (t *Task) calcSpeed(downloaded int64, usedTime float64) int64 {
t.speedArr = append(t.speedArr, downloaded)
if len(t.speedArr) > 6 {
t.speedArr = t.speedArr[1:]
}
var total int64
for _, v := range t.speedArr {
total += v
}
return int64(float64(total) / float64(len(t.speedArr)) / usedTime)
}
type DownloaderConfig struct {
Controller *controller.Controller
FetchBuilders []fetcher.FetcherBuilder
RefreshInterval int `json:"refreshInterval"` // RefreshInterval time duration to refresh task progress(ms)
Storage Storage
*DownloaderStoreConfig
}
func (cfg *DownloaderConfig) Init() *DownloaderConfig {
if cfg.Controller == nil {
cfg.Controller = controller.NewController()
}
if len(cfg.FetchBuilders) == 0 {
cfg.FetchBuilders = []fetcher.FetcherBuilder{
new(http.FetcherBuilder),
new(bt.FetcherBuilder),
}
}
if cfg.RefreshInterval == 0 {
cfg.RefreshInterval = 350
}
if cfg.Storage == nil {
cfg.Storage = NewMemStorage()
}
return cfg
}
// DownloaderStoreConfig is the config that can restore the downloader.
type DownloaderStoreConfig struct {
FirstLoad bool `json:"-"` // fromNoStore is the flag that the config is first time init and not from store
DownloadDir string `json:"downloadDir"` // DownloadDir is the default directory to save the downloaded files
MaxRunning int `json:"maxRunning"` // MaxRunning is the max running download count
ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
Extra map[string]any `json:"extra"` // Extra is the extra config
}
func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
if cfg.MaxRunning == 0 {
cfg.MaxRunning = 3
}
return cfg
}
-49
View File
@@ -1,49 +0,0 @@
package download
import (
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/monkeyWie/gopeed/internal/fetcher"
"github.com/monkeyWie/gopeed/pkg/base"
"github.com/monkeyWie/gopeed/pkg/util"
"sync"
"time"
)
type Task struct {
ID string `json:"id"`
Res *base.Resource `json:"res"`
Opts *base.Options `json:"opts"`
Status base.Status `json:"status"`
Progress *Progress `json:"progress"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"createdAt"`
fetcherBuilder fetcher.FetcherBuilder
fetcher fetcher.Fetcher
timer *util.Timer
lock *sync.Mutex
}
func NewTask() *Task {
id, err := gonanoid.New()
if err != nil {
panic(err)
}
return &Task{
ID: id,
Status: base.DownloadStatusReady,
CreatedAt: time.Now(),
}
}
func (t *Task) clone() *Task {
return &Task{
ID: t.ID,
Res: t.Res,
Opts: t.Opts,
Status: t.Status,
Progress: t.Progress,
Size: t.Size,
CreatedAt: t.CreatedAt,
}
}
+11
View File
@@ -0,0 +1,11 @@
package http
type ReqExtra struct {
Method string `json:"method"`
Header map[string]string `json:"header"`
Body string `json:"body"`
}
type OptsExtra struct {
Connections int `json:"connections"`
}
+137 -44
View File
@@ -1,92 +1,135 @@
package rest
import (
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/rest/model"
"github.com/gorilla/mux"
"github.com/monkeyWie/gopeed/pkg/base"
"github.com/monkeyWie/gopeed/pkg/download"
"github.com/monkeyWie/gopeed/pkg/rest/model"
"github.com/monkeyWie/gopeed/pkg/rest/util"
"io"
"net/http"
"net/url"
"strings"
)
func Resolve(w http.ResponseWriter, r *http.Request) {
var req base.Request
if util.ReadJson(w, r, &req) {
resource, err := Downloader.Resolve(&req)
if ReadJson(r, w, &req) {
rr, err := Downloader.Resolve(&req)
if err != nil {
util.WriteJson(w, http.StatusInternalServerError, model.NewResultWithMsg(err.Error()))
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
util.WriteJsonOk(w, model.NewResultWithData(resource))
WriteJson(w, model.NewOkResult(rr))
}
}
func CreateTask(w http.ResponseWriter, r *http.Request) {
var req model.CreateTask
if util.ReadJson(w, r, &req) {
if req.Opts.Connections == 0 {
req.Opts.Connections = getServerConfig().Connections
}
if req.Opts.Path == "" {
req.Opts.Path = getServerConfig().DownloadDir
}
taskId, err := Downloader.Create(req.Res, req.Opts)
if err != nil {
util.WriteJson(w, http.StatusInternalServerError, model.NewResultWithMsg(err.Error()))
if ReadJson(r, w, &req) {
var (
taskId string
err error
)
if req.Rid != "" {
taskId, err = Downloader.Create(req.Rid, req.Opts)
} else if req.Req != nil {
taskId, err = Downloader.CreateDirect(req.Req, req.Opts)
} else {
WriteJson(w, model.NewErrorResult("param invalid: rid or req", model.CodeInvalidParam))
return
}
util.WriteJsonOk(w, model.NewResultWithData(taskId))
if err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
WriteJson(w, model.NewOkResult(taskId))
}
}
func PauseTask(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
taskId := vars["id"]
if err := Downloader.Pause(taskId); err != nil {
util.WriteJson(w, http.StatusInternalServerError, model.NewResultWithMsg(err.Error()))
if taskId == "" {
WriteJson(w, model.NewErrorResult("param invalid: id", model.CodeInvalidParam))
return
}
util.WriteJsonOk(w, nil)
if err := Downloader.Pause(taskId); err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
WriteJson(w, model.NewNilResult())
}
func ContinueTask(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
taskId := vars["id"]
if err := Downloader.Continue(taskId); err != nil {
util.WriteJson(w, http.StatusInternalServerError, model.NewResultWithMsg(err.Error()))
if taskId == "" {
WriteJson(w, model.NewErrorResult("param invalid: id", model.CodeInvalidParam))
return
}
util.WriteJsonOk(w, nil)
if err := Downloader.Continue(taskId); err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
WriteJson(w, model.NewNilResult())
}
func PauseAllTask(w http.ResponseWriter, r *http.Request) {
if err := Downloader.PauseAll(); err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
WriteJson(w, model.NewNilResult())
}
func ContinueAllTask(w http.ResponseWriter, r *http.Request) {
if err := Downloader.ContinueAll(); err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
WriteJson(w, model.NewNilResult())
}
func DeleteTask(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
taskId := vars["id"]
force := r.FormValue("force")
if err := Downloader.Delete(taskId, force == "true"); err != nil {
util.WriteJson(w, http.StatusInternalServerError, model.NewResultWithMsg(err.Error()))
if taskId == "" {
WriteJson(w, model.NewErrorResult("param invalid: id", model.CodeInvalidParam))
return
}
util.WriteJsonOk(w, nil)
if err := Downloader.Delete(taskId, force == "true"); err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
WriteJson(w, model.NewNilResult())
}
func GetTask(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
taskId := vars["id"]
task := Downloader.GetTask(taskId)
if task == nil {
util.WriteJson(w, http.StatusNotFound, model.NewResultWithMsg("task not found"))
if taskId == "" {
WriteJson(w, model.NewErrorResult("param invalid: id", model.CodeInvalidParam))
return
}
util.WriteJsonOk(w, model.NewResultWithData(task))
task := Downloader.GetTask(taskId)
if task == nil {
WriteJson(w, model.NewErrorResult("task not found", model.CodeTaskNotFound))
return
}
WriteJson(w, model.NewOkResult(task))
}
func GetTasks(w http.ResponseWriter, r *http.Request) {
status := r.FormValue("status")
if status == "" {
util.WriteJson(w, http.StatusBadRequest, model.NewResultWithMsg("param is required: status"))
return
status = strings.Join([]string{
string(base.DownloadStatusReady),
string(base.DownloadStatusRunning),
string(base.DownloadStatusPause),
string(base.DownloadStatusError),
string(base.DownloadStatusDone),
}, ",")
}
statusArr := strings.Split(status, ",")
tasks := Downloader.GetTasks()
@@ -98,26 +141,76 @@ func GetTasks(w http.ResponseWriter, r *http.Request) {
}
}
}
util.WriteJsonOk(w, model.NewResultWithData(result))
WriteJson(w, model.NewOkResult(result))
}
func GetConfig(w http.ResponseWriter, r *http.Request) {
util.WriteJsonOk(w, model.NewResultWithData(getServerConfig()))
WriteJson(w, model.NewOkResult(getServerConfig()))
}
func PutConfig(w http.ResponseWriter, r *http.Request) {
var cfg model.ServerConfig
if util.ReadJson(w, r, &cfg) {
var cfg download.DownloaderStoreConfig
if ReadJson(r, w, &cfg) {
if err := Downloader.PutConfig(&cfg); err != nil {
util.WriteJson(w, http.StatusInternalServerError, model.NewResultWithMsg(err.Error()))
WriteJson(w, model.NewErrorResult(err.Error()))
return
}
}
util.WriteJsonOk(w, nil)
WriteJson(w, model.NewNilResult())
}
func getServerConfig() *model.ServerConfig {
var cfg model.ServerConfig
Downloader.GetConfig(&cfg)
return &cfg
func DoProxy(w http.ResponseWriter, r *http.Request) {
target := r.Header.Get("X-Target-Uri")
if target == "" {
writeError(w, "param invalid: X-Target-Uri")
return
}
targetUrl, err := url.Parse(target)
if err != nil {
writeError(w, err.Error())
return
}
r.RequestURI = ""
r.URL = targetUrl
r.Host = targetUrl.Host
resp, err := http.DefaultClient.Do(r)
if err != nil {
writeError(w, err.Error())
return
}
defer resp.Body.Close()
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Set(k, v)
}
}
w.WriteHeader(resp.StatusCode)
buf, err := io.ReadAll(resp.Body)
if err != nil {
writeError(w, err.Error())
return
}
w.Write(buf)
//var reader io.ReadCloser
//switch resp.Header.Get("Content-Encoding") {
//case "gzip":
// reader, err = gzip.NewReader(resp.Body)
// defer reader.Close()
//default:
// reader = resp.Body
//}
//if _, err := io.Copy(w, resp.Body); err != nil {
// writeError(w, err.Error())
// return
//}
}
func writeError(w http.ResponseWriter, msg string) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(msg))
}
func getServerConfig() *download.DownloaderStoreConfig {
cfg, _ := Downloader.GetConfig()
return cfg
}
+6
View File
@@ -0,0 +1,6 @@
package rest
type Config struct {
Host string `json:"host"`
Port int `json:"port"`
}
+7
View File
@@ -0,0 +1,7 @@
package model
type Command struct {
Protocol string `json:"protocol"`
Action string `json:"action"`
Params any `json:"params"`
}
+34 -8
View File
@@ -1,21 +1,47 @@
package model
type RespCode int
const (
CodeOk RespCode = 0
// CodeError is the common error code
CodeError RespCode = 1000
// CodeUnauthorized is the error code for unauthorized
CodeUnauthorized RespCode = 1001
// CodeInvalidParam is the error code for invalid parameter
CodeInvalidParam RespCode = 1002
// CodeTaskNotFound is the error code for task not found
CodeTaskNotFound RespCode = 2001
)
type Result[T any] struct {
Msg string `json:"msg"`
Data T `json:"data"`
Code RespCode `json:"code"`
Msg string `json:"msg"`
Data T `json:"data"`
}
func NewResult[T any](msg string, data T) *Result[T] {
func NewOkResult[T any](data T) *Result[T] {
return &Result[T]{
Msg: msg,
Code: CodeOk,
Data: data,
}
}
func NewResultWithMsg(msg string) *Result[any] {
return NewResult[any](msg, nil)
func NewNilResult() *Result[any] {
return &Result[any]{
Code: CodeOk,
}
}
func NewResultWithData[T any](data T) *Result[T] {
return NewResult[T]("", data)
func NewErrorResult(msg string, code ...RespCode) *Result[any] {
// if code is not provided, the default code is CodeError
c := CodeError
if len(code) > 0 {
c = code[0]
}
return &Result[any]{
Code: c,
Msg: msg,
}
}
+8 -25
View File
@@ -12,9 +12,10 @@ const (
type StartConfig struct {
Network string `json:"network"`
Address string `json:"address"`
RefreshInterval int `json:"refreshInterval"`
Storage Storage `json:"storage"`
StorageDir string `json:"storageDir"`
RefreshInterval int `json:"refreshInterval"`
ApiToken string `json:"apiToken"`
WebEnable bool
WebFS fs.FS
@@ -24,6 +25,12 @@ func (cfg *StartConfig) Init() *StartConfig {
if cfg.Network == "" {
cfg.Network = "tcp"
}
if cfg.Address == "" {
cfg.Address = "127.0.0.1:0"
}
if cfg.RefreshInterval == 0 {
cfg.RefreshInterval = 350
}
if cfg.Storage == "" {
cfg.Storage = StorageBolt
}
@@ -32,27 +39,3 @@ func (cfg *StartConfig) Init() *StartConfig {
}
return cfg
}
// ServerConfig is present in the database
type ServerConfig struct {
Host string `json:"host"`
Port int `json:"port"`
Connections int `json:"connections"`
DownloadDir string `json:"downloadDir"`
Extra map[string]any `json:"extra"`
}
func (cfg *ServerConfig) Init() *ServerConfig {
if cfg.Host == "" {
cfg.Host = "127.0.0.1"
}
if cfg.Port < 0 {
cfg.Port = 0
}
if cfg.Connections <= 0 {
cfg.Connections = 16
}
return cfg
}
+4 -3
View File
@@ -1,8 +1,9 @@
package model
import "github.com/monkeyWie/gopeed/pkg/base"
import "github.com/GopeedLab/gopeed/pkg/base"
type CreateTask struct {
Res *base.Resource `json:"res"`
Opts *base.Options `json:"opts"`
Rid string `json:"rid"`
Req *base.Request `json:"req"`
Opts *base.Options `json:"opts"`
}
+39 -32
View File
@@ -2,14 +2,14 @@ package rest
import (
"context"
"encoding/json"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/rest/model"
"github.com/GopeedLab/gopeed/pkg/util"
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/monkeyWie/gopeed/pkg/download"
"github.com/monkeyWie/gopeed/pkg/rest/model"
"github.com/monkeyWie/gopeed/pkg/util"
"net"
"net/http"
"strconv"
)
var (
@@ -57,28 +57,25 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
}
startCfg.Init()
downloadCfg := &download.DownloaderConfig{}
downloadCfg := &download.DownloaderConfig{
RefreshInterval: startCfg.RefreshInterval,
}
if startCfg.Storage == model.StorageBolt {
downloadCfg.Storage = download.NewBoltStorage(startCfg.StorageDir)
} else {
downloadCfg.Storage = download.NewMemStorage()
}
downloadCfg.RefreshInterval = startCfg.RefreshInterval
Downloader = download.NewDownloader(downloadCfg.Init())
downloadCfg.Init()
Downloader = download.NewDownloader(downloadCfg)
if err := Downloader.Setup(); err != nil {
return nil, nil, err
}
serverConfig := getAndPutServerConfig(startCfg)
var address string
if startCfg.Network == "unix" {
address = startCfg.Address
util.SafeRemove(address)
} else {
address = net.JoinHostPort(serverConfig.Host, strconv.Itoa(serverConfig.Port))
util.SafeRemove(startCfg.Address)
}
listener, err := net.Listen(startCfg.Network, address)
listener, err := net.Listen(startCfg.Network, startCfg.Address)
if err != nil {
return nil, nil, err
}
@@ -88,38 +85,48 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
r.Methods(http.MethodPost).Path("/api/v1/tasks").HandlerFunc(CreateTask)
r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/pause").HandlerFunc(PauseTask)
r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/continue").HandlerFunc(ContinueTask)
r.Methods(http.MethodPut).Path("/api/v1/tasks/pause").HandlerFunc(PauseAllTask)
r.Methods(http.MethodPut).Path("/api/v1/tasks/continue").HandlerFunc(ContinueAllTask)
r.Methods(http.MethodDelete).Path("/api/v1/tasks/{id}").HandlerFunc(DeleteTask)
r.Methods(http.MethodGet).Path("/api/v1/tasks/{id}").HandlerFunc(GetTask)
r.Methods(http.MethodGet).Path("/api/v1/tasks").HandlerFunc(GetTasks)
r.Methods(http.MethodGet).Path("/api/v1/config").HandlerFunc(GetConfig)
r.Methods(http.MethodPut).Path("/api/v1/config").HandlerFunc(PutConfig)
r.Path("/api/v1/proxy").HandlerFunc(DoProxy)
if startCfg.WebEnable {
r.PathPrefix("/").Handler(http.FileServer(http.FS(startCfg.WebFS)))
}
if startCfg.ApiToken != "" {
r.Use(func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Api-Token") != startCfg.ApiToken {
WriteJson(w, model.NewErrorResult("invalid token", model.CodeUnauthorized))
return
}
h.ServeHTTP(w, r)
})
})
}
srv = &http.Server{Handler: handlers.CORS(
handlers.AllowedHeaders([]string{"X-Requested-With", "Content-Type", "Authorization"}),
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}),
handlers.AllowedHeaders([]string{"Content-Type", "X-Api-Token", "X-Target-Uri"}),
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}),
handlers.AllowedOrigins([]string{"*"}),
)(r)}
return srv, listener, nil
}
func getAndPutServerConfig(startCfg *model.StartConfig) *model.ServerConfig {
var serverConfig model.ServerConfig
exist, err := Downloader.GetConfig(&serverConfig)
if err != nil {
// TODO log
func ReadJson(r *http.Request, w http.ResponseWriter, v any) bool {
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return false
}
// first start
if !exist {
if startCfg.Network == "tcp" {
host, port, _ := net.SplitHostPort(startCfg.Address)
serverConfig.Host = host
serverConfig.Port, _ = strconv.Atoi(port)
}
serverConfig.Init()
Downloader.PutConfig(serverConfig)
}
return &serverConfig
return true
}
func WriteJson(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(v)
}
+169 -45
View File
@@ -2,13 +2,14 @@ package rest
import (
"bytes"
"crypto/md5"
"encoding/json"
"errors"
"fmt"
"github.com/monkeyWie/gopeed/internal/test"
"github.com/monkeyWie/gopeed/pkg/base"
"github.com/monkeyWie/gopeed/pkg/download"
"github.com/monkeyWie/gopeed/pkg/rest/model"
"github.com/GopeedLab/gopeed/internal/test"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/rest/model"
"io"
"net"
"net/http"
@@ -22,10 +23,17 @@ import (
var (
restPort int
taskReq = &base.Request{}
taskReq = &base.Request{
Extra: map[string]any{
"method": "",
"header": map[string]string{
"User-Agent": "gopeed",
},
"body": "",
},
}
taskRes = &base.Resource{
Name: test.BuildName,
Req: taskReq,
Size: test.BuildSize,
Range: true,
Files: []*base.FileInfo{
@@ -37,20 +45,22 @@ var (
},
}
createReq = &model.CreateTask{
Res: taskRes,
Req: taskReq,
Opts: &base.Options{
Path: test.Dir,
Name: test.DownloadName,
Connections: 2,
Path: test.Dir,
Name: test.DownloadName,
Extra: map[string]any{
"connections": 2,
},
},
}
)
func TestResolve(t *testing.T) {
doTest(func() {
resp := httpRequestCheckOk[*base.Resource](http.MethodPost, "/api/v1/resolve", taskReq)
if !reflect.DeepEqual(taskRes, resp) {
t.Errorf("Resolve() got = %v, want %v", resp, taskRes)
resp := httpRequestCheckOk[*download.ResolveResult](http.MethodPost, "/api/v1/resolve", taskReq)
if !test.JsonEqual(taskRes, resp.Res) {
t.Errorf("Resolve() got = %v, want %v", test.ToJson(resp.Res), test.ToJson(taskRes))
}
})
}
@@ -134,22 +144,56 @@ func TestPauseAndContinueTask(t *testing.T) {
})
}
func TestPauseAllAndContinueALLTasks(t *testing.T) {
doTest(func() {
cfg, err := Downloader.GetConfig()
if err != nil {
t.Fatal(err)
}
createAndPause := func() {
taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createReq)
httpRequestCheckOk[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil)
}
total := cfg.MaxRunning + 2
for i := 0; i < total; i++ {
createAndPause()
}
// continue all
httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/continue", nil)
tasks := httpRequestCheckOk[[]*download.Task](http.MethodGet, fmt.Sprintf("/api/v1/tasks?status=%s", base.DownloadStatusRunning), nil)
if len(tasks) != cfg.MaxRunning {
t.Errorf("ContinueAllTasks() got = %v, want %v", len(tasks), cfg.MaxRunning)
}
// pause all
httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/pause", nil)
tasks = httpRequestCheckOk[[]*download.Task](http.MethodGet, fmt.Sprintf("/api/v1/tasks?status=%s,%s", base.DownloadStatusReady, base.DownloadStatusPause), nil)
if len(tasks) != total {
t.Errorf("PauseAllTasks() got = %v, want %v", len(tasks), total)
}
})
}
func TestDeleteTask(t *testing.T) {
doTest(func() {
taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createReq)
time.Sleep(time.Millisecond * 200)
httpRequestCheckOk[any](http.MethodDelete, "/api/v1/tasks/"+taskId, nil)
code, _ := httpRequest[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil)
checkCode(code, http.StatusNotFound)
checkCode(code, model.CodeTaskNotFound)
})
}
func TestDeleteTaskForce(t *testing.T) {
doTest(func() {
taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createReq)
time.Sleep(time.Millisecond * 500)
time.Sleep(time.Millisecond * 200)
httpRequestCheckOk[any](http.MethodDelete, "/api/v1/tasks/"+taskId+"?force=true", nil)
code, _ := httpRequest[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil)
checkCode(code, http.StatusNotFound)
checkCode(code, model.CodeTaskNotFound)
if _, err := os.Stat(test.DownloadFile); !errors.Is(err, os.ErrNotExist) {
t.Errorf("DeleteTaskForce() got = %v, want %v", err, os.ErrNotExist)
}
@@ -168,8 +212,7 @@ func TestGetTasks(t *testing.T) {
httpRequestCheckOk[string](http.MethodPost, fmt.Sprintf("/api/v1/tasks?status=%s,%s",
base.DownloadStatusReady, base.DownloadStatusRunning), createReq)
code, _ := httpRequest[[]*download.Task](http.MethodGet, "/api/v1/tasks", nil)
checkCode(code, http.StatusBadRequest)
httpRequestCheckOk[[]*download.Task](http.MethodGet, "/api/v1/tasks", nil)
wg.Wait()
r := httpRequestCheckOk[[]*download.Task](http.MethodGet, fmt.Sprintf("/api/v1/tasks?status=%s",
@@ -187,26 +230,77 @@ func TestGetTasks(t *testing.T) {
func TestGetAndPutConfig(t *testing.T) {
doTest(func() {
cfg := httpRequestCheckOk[*model.ServerConfig](http.MethodGet, "/api/v1/config", nil)
cfg.Port = 8888
cfg.Connections = 32
cfg := httpRequestCheckOk[*download.DownloaderStoreConfig](http.MethodGet, "/api/v1/config", nil)
cfg.DownloadDir = "./download"
cfg.Extra = map[string]any{
"serverConfig": &Config{
Host: "127.0.0.1",
Port: 8080,
},
"theme": "dark",
}
httpRequestCheckOk[any](http.MethodPut, "/api/v1/config", cfg)
newCfg := httpRequestCheckOk[*model.ServerConfig](http.MethodGet, "/api/v1/config", nil)
if !reflect.DeepEqual(newCfg, cfg) {
t.Errorf("GetAndPutConfig() got = %v, want %v", newCfg, cfg)
newCfg := httpRequestCheckOk[*download.DownloaderStoreConfig](http.MethodGet, "/api/v1/config", nil)
if !test.JsonEqual(cfg, newCfg) {
t.Errorf("GetAndPutConfig() got = %v, want %v", test.ToJson(newCfg), test.ToJson(cfg))
}
})
}
func TestDoProxy(t *testing.T) {
doTest(func() {
code, respBody := doHttpRequest0(http.MethodGet, "/api/v1/proxy", map[string]string{
"X-Target-Uri": "https://github.com/GopeedLab/gopeed/raw/695da7ea87d2b455552b709d3cb4d7879484d4d1/README.md",
}, nil)
if code != http.StatusOK {
t.Errorf("DoProxy() got = %v, want %v", code, http.StatusOK)
}
want := "4ee193b676f1ebb2ad810e016350d52a"
got := fmt.Sprintf("%x", md5.Sum(respBody))
if got != want {
t.Errorf("DoProxy() got = %v, want %v", got, want)
}
})
doTest(func() {
code, _ := doHttpRequest0(http.MethodGet, "/api/v1/proxy", map[string]string{
"X-Target-Uri": "https://github.com/GopeedLab/gopeed/raw/695da7ea87d2b455552b709d3cb4d7879484d4d1/NOT_FOUND",
}, nil)
if code != http.StatusNotFound {
t.Errorf("DoProxy() got = %v, want %v", code, http.StatusNotFound)
}
})
}
func TestAuthorization(t *testing.T) {
var cfg = &model.StartConfig{}
cfg.Init()
cfg.ApiToken = "123456"
fileListener := doStart(cfg)
defer func() {
if err := fileListener.Close(); err != nil {
panic(err)
}
Stop()
}()
code, _ := doHttpRequest[any](http.MethodGet, "/api/v1/config", nil, nil)
checkCode(code, model.CodeUnauthorized)
code, _ = doHttpRequest[any](http.MethodGet, "/api/v1/config", map[string]string{
"X-Api-Token": cfg.ApiToken,
}, nil)
checkCode(code, model.CodeOk)
}
func doTest(handler func()) {
testFunc := func(storage model.Storage) {
fileListener := doStart(storage)
var cfg = &model.StartConfig{}
cfg.Init()
cfg.Storage = storage
fileListener := doStart(cfg)
defer func() {
if err := fileListener.Close(); err != nil {
panic(err)
@@ -214,6 +308,16 @@ func doTest(handler func()) {
Stop()
}()
defer Downloader.Clear()
defer func() {
tasks := Downloader.GetTasks()
taskIds := make([]string, len(tasks))
for i, task := range tasks {
taskIds[i] = task.ID
}
for _, id := range taskIds {
Downloader.Delete(id, true)
}
}()
taskReq.URL = "http://" + fileListener.Addr().String() + "/" + test.BuildName
handler()
}
@@ -221,10 +325,8 @@ func doTest(handler func()) {
testFunc(model.StorageBolt)
}
func doStart(storage model.Storage) net.Listener {
port, err := Start((&model.StartConfig{
Storage: storage,
}).Init())
func doStart(cfg *model.StartConfig) net.Listener {
port, err := Start(cfg)
if err != nil {
panic(err)
}
@@ -232,42 +334,64 @@ func doStart(storage model.Storage) net.Listener {
return test.StartTestFileServer()
}
func httpRequest[T any](method string, path string, req any) (int, *model.Result[T]) {
var body io.Reader
if req != nil {
buf, _ := json.Marshal(req)
body = bytes.NewBuffer(buf)
func doHttpRequest0(method string, path string, headers map[string]string, body any) (int, []byte) {
var reader io.Reader
if body != nil {
buf, _ := json.Marshal(body)
reader = bytes.NewBuffer(buf)
}
request, err := http.NewRequest(method, fmt.Sprintf("http://127.0.0.1:%d%s", restPort, path), body)
request, err := http.NewRequest(method, fmt.Sprintf("http://127.0.0.1:%d%s", restPort, path), reader)
if err != nil {
panic(err)
}
if headers != nil {
for k, v := range headers {
request.Header.Set(k, v)
}
}
response, err := http.DefaultClient.Do(request)
if err != nil {
panic(err)
}
defer response.Body.Close()
var r model.Result[T]
if err := json.NewDecoder(response.Body).Decode(&r); err != nil {
respBody, err := io.ReadAll(response.Body)
if err != nil {
panic(err)
}
return response.StatusCode, &r
return response.StatusCode, respBody
}
func httpRequestCheckOk[T any](method string, path string, req any) T {
code, result := httpRequest[T](method, path, req)
func doHttpRequest[T any](method string, path string, headers map[string]string, body any) (int, *model.Result[T]) {
statusCode, respBody := doHttpRequest0(method, path, headers, body)
if statusCode != http.StatusOK {
panic(fmt.Sprintf("http request failed, status code: %d", statusCode))
}
var r model.Result[T]
if err := json.Unmarshal(respBody, &r); err != nil {
panic(err)
}
return int(r.Code), &r
}
func httpRequest[T any](method string, path string, body any) (int, *model.Result[T]) {
return doHttpRequest[T](method, path, nil, body)
}
func httpRequestCheckOk[T any](method string, path string, body any) T {
code, result := httpRequest[T](method, path, body)
checkOk(code)
return result.Data
}
func checkOk(code int) {
checkCode(code, 200)
checkCode(code, model.CodeOk)
}
func checkCode(code int, exceptCode int) {
if code != exceptCode {
func checkCode(code int, exceptCode model.RespCode) {
if code != int(exceptCode) {
panic(fmt.Sprintf("code got = %d, want %d", code, exceptCode))
}
}
-25
View File
@@ -1,25 +0,0 @@
package util
import (
"encoding/json"
"github.com/monkeyWie/gopeed/pkg/rest/model"
"net/http"
)
func ReadJson(w http.ResponseWriter, r *http.Request, v any) bool {
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
WriteJson(w, 500, model.NewResultWithMsg(err.Error()))
return false
}
return true
}
func WriteJsonOk(w http.ResponseWriter, v any) {
WriteJson(w, 200, v)
}
func WriteJson(w http.ResponseWriter, code int, v any) {
w.WriteHeader(code)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
+14
View File
@@ -0,0 +1,14 @@
package util
import "encoding/json"
func MapToStruct(s any, v any) error {
if s == nil {
return nil
}
b, err := json.Marshal(s)
if err != nil {
return err
}
return json.Unmarshal(b, v)
}
+45 -27
View File
@@ -2,13 +2,15 @@ package util
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
syspath "path"
"strings"
)
func Dir(path string) string {
dir := filepath.Dir(path)
dir := syspath.Dir(path)
if dir == "." {
return ""
}
@@ -19,7 +21,7 @@ func Filepath(path string, originName string, customName string) string {
if customName == "" {
customName = originName
}
return filepath.Join(path, customName)
return syspath.Join(path, customName)
}
// SafeRemove remove file safely, ignoring errors if the path does not exist.
@@ -30,39 +32,55 @@ func SafeRemove(name string) error {
return nil
}
// SafeRemoveAll remove file and parent directories safely
func SafeRemoveAll(path string, names []string) error {
for _, name := range names {
err := SafeRemove(filepath.Join(path, name))
if err != nil {
return err
// CheckDuplicateAndRename rename duplicate file, add suffix (1) (2) ...
// if file name is a.txt, rename to a (1).txt
// if directory name is a, rename to a (1)
// return new name
func CheckDuplicateAndRename(path string) (string, error) {
dir := syspath.Dir(path)
name := syspath.Base(path)
// if file not exists, return directly
_, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return name, nil
}
if err := safeRemoveParent(path, name); err != nil {
return err
return "", err
}
index := strings.LastIndex(name, ".")
var nameTpl string
if index == -1 {
nameTpl = name + " (%d)"
} else {
nameTpl = name[:index] + " (%d)" + name[index:]
}
for i := 1; ; i++ {
newName := fmt.Sprintf(nameTpl, i)
newPath := syspath.Join(dir, newName)
if _, err := os.Stat(newPath); os.IsNotExist(err) {
return newName, nil
}
}
return nil
}
func safeRemoveParent(path string, subPath string) error {
currPath := filepath.Dir(subPath)
if currPath == "." {
return nil
// GetSingleDir get the top level single folder name,if not exist, return empty string
func GetSingleDir(paths []string) string {
if len(paths) == 0 {
return ""
}
// if directory is empty, remove it
dir := filepath.Join(path, filepath.Dir(subPath))
empty, err := isEmpty(dir)
if err != nil {
return err
split := strings.Split(paths[0], "/")
if len(split) == 0 || split[0] == "" {
return ""
}
if empty {
err = SafeRemove(dir)
if err != nil {
return err
dir := split[0]
for i := 1; i < len(paths); i++ {
if !strings.HasPrefix(paths[i], dir) {
return ""
}
return safeRemoveParent(path, currPath)
}
return nil
return dir
}
// check directory is empty
+93 -90
View File
@@ -2,7 +2,6 @@ package util
import (
"os"
"path/filepath"
"testing"
)
@@ -87,10 +86,13 @@ func TestFilepath(t *testing.T) {
func TestSafeRemove(t *testing.T) {
name := "test_safe_remove.data"
_, err := os.Create(name)
file, err := os.Create(name)
if err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
if err := SafeRemove(name); err != nil {
t.Fatal(err)
}
@@ -103,96 +105,97 @@ func TestSafeRemove(t *testing.T) {
}
}
func TestSafeRemoveAll(t *testing.T) {
testDir := "test_dir"
err := os.Mkdir(testDir, 0755)
func TestGetSingleDir(t *testing.T) {
type args struct {
paths []string
}
tests := []struct {
name string
args args
want string
}{
{
name: "empty",
args: args{
paths: []string{},
},
want: "",
},
{
name: "blank",
args: args{
paths: []string{""},
},
want: "",
},
{
name: "blank multi",
args: args{
paths: []string{"", "a"},
},
want: "",
},
{
name: "multi dir not single",
args: args{
paths: []string{"a", "b"},
},
want: "",
},
{
name: "single dir",
args: args{
paths: []string{"a"},
},
want: "a",
},
{
name: "multi dir single",
args: args{
paths: []string{"a/b", "a/c"},
},
want: "a",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetSingleDir(tt.args.paths); got != tt.want {
t.Errorf("IsSingleDir() = %v, want %v", got, tt.want)
}
})
}
}
func TestCheckDuplicateAndRename(t *testing.T) {
doCheckDuplicateAndRename(t, []string{}, "a.txt", "a.txt")
doCheckDuplicateAndRename(t, []string{"a.txt"}, "a.txt", "a (1).txt")
doCheckDuplicateAndRename(t, []string{"a.txt", "a (1).txt"}, "a.txt", "a (2).txt")
doCheckDuplicateAndRename(t, []string{}, "a", "a")
doCheckDuplicateAndRename(t, []string{"a"}, "a", "a (1)")
doCheckDuplicateAndRename(t, []string{"a", "a (1)"}, "a", "a (2)")
}
func doCheckDuplicateAndRename(t *testing.T, exitsPaths []string, path string, except string) {
for _, path := range exitsPaths {
if err := os.MkdirAll(path, 0755); err != nil {
t.Fatal(err)
}
}
defer func() {
for _, path := range exitsPaths {
if err := os.RemoveAll(path); err != nil {
t.Fatal(err)
}
}
}()
got, err := CheckDuplicateAndRename(path)
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(testDir)
doSafeRemoveAll(t, testDir, []string{
"1.txt",
}, []string{})
doSafeRemoveAll(t, testDir, []string{
"1.txt",
}, []string{
"2.txt",
}, "2.txt")
doSafeRemoveAll(t, testDir, []string{
"1.txt",
"a/b/c/1.txt",
"a/b/1.txt",
"a/1.txt",
}, []string{})
doSafeRemoveAll(t, testDir, []string{
"1.txt",
"a/b/c/1.txt",
"a/b/1.txt",
"a/1.txt",
}, []string{
"a/b/2.txt",
}, "a/b/2.txt")
doSafeRemoveAll(t, testDir, []string{
"1.txt",
"a/b/c/1.txt",
"a/b/1.txt",
"a/1.txt",
}, []string{
"a/2.txt",
}, "a/2.txt")
}
func doSafeRemoveAll(t *testing.T, path string, downloadNames []string, otherNames []string, exist ...string) {
preCreate(t, path, downloadNames)
preCreate(t, path, otherNames)
if err := SafeRemoveAll(path, downloadNames); err != nil {
t.Fatal(err)
}
if len(exist) == 0 {
for _, name := range downloadNames {
filePath := filepath.Join(path, name)
if isExist(filePath) {
t.Fatalf("file %s should not exist", filePath)
}
subDirPath := filepath.Dir(name)
if subDirPath != "." {
dirPath := filepath.Join(path, subDirPath)
if isExist(dirPath) {
t.Fatalf("dir %s should not exist", dirPath)
}
}
}
return
}
for _, name := range exist {
if !isExist(filepath.Join(path, name)) {
t.Fatalf("file %s should exist", name)
}
if got != except {
t.Errorf("CheckDuplicateAndRename() = %v, want %v", got, except)
}
}
func preCreate(t *testing.T, path string, names []string) {
for _, name := range names {
fullPath := filepath.Join(path, name)
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
t.Fatal(err)
}
_, err := os.Create(fullPath)
if err != nil {
t.Fatal(err)
}
}
}
func isExist(path string) bool {
_, err := os.Stat(path)
return err == nil || os.IsExist(err)
}
+6 -1
View File
@@ -2,12 +2,17 @@ package util
import "time"
// 计时器
type Timer struct {
t int64
used int64
}
func NewTimer(used int64) *Timer {
return &Timer{
used: used,
}
}
func (t *Timer) Start() {
t.t = time.Now().UnixNano()
}
+50
View File
@@ -0,0 +1,50 @@
package util
import "testing"
func TestParseSchema(t *testing.T) {
type args struct {
url string
}
tests := []struct {
name string
args args
want string
}{
{
name: "http",
args: args{
url: "http://www.google.com",
},
want: "HTTP",
},
{
name: "https",
args: args{
url: "https://www.google.com",
},
want: "HTTPS",
},
{
name: "file",
args: args{
url: "file:///home/bt.torrent",
},
want: "FILE",
},
{
name: "file-no-scheme",
args: args{
url: "/home/bt.torrent",
},
want: "FILE",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ParseSchema(tt.args.url); got != tt.want {
t.Errorf("ParseSchema() = %v, want %v", got, tt.want)
}
})
}
}
+9 -4
View File
@@ -29,7 +29,8 @@
.packages
.pub-cache/
.pub/
/build/
build/
.fvm/
# Web related
@@ -54,6 +55,10 @@ android/app/libs/libgopeed-sources.jar
debian/packages
generated_plugin_registrant.cc
generated_plugin_registrant.h
generated_plugins.cmake
linux/flutter/generated_plugin_registrant.cc
linux/flutter/generated_plugin_registrant.h
linux/flutter/generated_plugins.cmake
macos/Flutter/GeneratedPluginRegistrant.swift
windows/flutter/generated_plugin_registrant.cc
windows/flutter/generated_plugin_registrant.h
windows/flutter/generated_plugins.cmake
+5 -20
View File
@@ -4,7 +4,7 @@
# This file should be version controlled.
version:
revision: f1875d570e39de09040c8f79aa13cc56baab8db1
revision: 7048ed95a5ad3e43d697e0c397464193991fc230
channel: stable
project_type: app
@@ -13,26 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
- platform: android
create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
- platform: ios
create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
- platform: linux
create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
- platform: macos
create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
- platform: web
create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
- platform: windows
create_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
base_revision: f1875d570e39de09040c8f79aa13cc56baab8db1
create_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
base_revision: 7048ed95a5ad3e43d697e0c397464193991fc230
# User provided section
-16
View File
@@ -1,16 +0,0 @@
# gopeed
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
+15 -4
View File
@@ -48,16 +48,27 @@ android {
// You can update the following values to match your application needs.
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
minSdkVersion 19
targetSdkVersion flutter.targetSdkVersion
targetSdkVersion 29
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
signingConfigs {
release {
keyAlias "upload"
keyPassword System.getenv("APK_KEY_PASSWORD")
storeFile file("upload-keystore.jks")
storePassword System.getenv("APK_STORE_PASSWORD")
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig signingConfigs.debug
if (signingConfigs.release.keyPassword != null) {
signingConfig signingConfigs.release
} else {
signingConfig signingConfigs.debug
}
}
}
Whitespace-only changes.
@@ -4,9 +4,5 @@
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<application android:requestLegacyExternalStorage="true"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
</manifest>
@@ -2,11 +2,8 @@
package="com.gopeed">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<application
android:requestLegacyExternalStorage="true"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:label="Gopeed">
@@ -15,9 +12,54 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:launchMode="singleInstance"
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="file"
android:host="*"
android:pathPattern=".*torrent" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="file"
android:host="*"
android:mimeType="application/x-bittorrent" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="content"
android:host="*"
android:pathPattern=".*torrent" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="content"
android:host="*"
android:mimeType="application/x-bittorrent" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="magnet" />
</intent-filter>
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
@@ -4,9 +4,5 @@
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<application android:requestLegacyExternalStorage="true"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
</manifest>
Binary file not shown.
+62
View File
@@ -0,0 +1,62 @@
{
"label": "English",
"error": "Error",
"tip": "Tip",
"confirm": "Confirm",
"cancel": "Cancel",
"selectAll": "Select All",
"task": "Task",
"downloading": "downloading",
"downloaded": "downloaded",
"setting": "Setting",
"donate": "Donate",
"exit": "Exit",
"create": "Create Task",
"downloadLink": "Download Link",
"downloadLinkValid": "Please enter the download link",
"downloadLinkHit": "Please enter the download link, HTTP/HTTPS/MAGNET supported@append",
"downloadLinkHitDesktop": ", or drag the torrent file here directly",
"download": "Download",
"noFileSelected": "Please select at least one file to continue.",
"noStoragePermission": "Storage permission required",
"selectFile": "Select File",
"basic": "Basic",
"advanced": "Advanced",
"general": "General",
"downloadDir": "Download Directory",
"downloadDirValid": "Please select the download directory",
"connections": "Connections",
"maxRunning": "Max Running Tasks",
"items": "@count items",
"subscribeTracker": "Subscribe Tracker",
"subscribeFail": "Subscribe failed, please check network or try again later",
"update": "Update",
"updateDaily": "Update daily",
"lastUpdate": "Last update: @time",
"addTracker": "Add Tracker",
"addTrackerHit": "Please enter the tracker server url, one per line",
"ui": "UI",
"theme": "Theme",
"themeSystem": "System",
"themeLight": "Light",
"themeDark": "Dark",
"locale": "Language",
"about": "About",
"homepage": "Homepage",
"version": "Version",
"protocol": "Protocol",
"port": "Port",
"apiToken": "API Token",
"notSet": "NS",
"set": "SET",
"effectAfterRestart": "Effect after restart",
"startAll": "Start All",
"pauseAll": "Pause All",
"deleteTask": "Delete Task",
"deleteTaskTip": "Keep downloaded files",
"delete": "Delete",
"newVersionTitle": "Discover new version @version",
"newVersionUpdate": "Download Now",
"newVersionLater": "Later"
}
+58
View File
@@ -0,0 +1,58 @@
{
"label": "فارسی",
"error": "اررور",
"tip": "Tip",
"confirm": "تایید",
"cancel": "انصراف",
"selectAll": "انتخاب همه",
"task": "کار",
"setting": "تنظیمات",
"donate": "کمک مالی",
"exit": "خروج",
"create": "ایجاد کار",
"downloading": "دانلود کردن",
"downloaded": "دانلود شدن",
"downloadLink": "لینک دانلود",
"downloadLinkValid": "لطفا لینک دانلود را وارد کنید",
"download": "دانلود",
"noFileSelected": "لطفا حداقل یک فایل را جهت دانلود انتخاب کنید.",
"noStoragePermission": "مجوز ذخیره سازی مورد نیاز است",
"selectFile": "انتخاب فایل",
"basic": "پایه",
"advanced": "پیشرفته",
"general": "عمومی",
"downloadDir": "دایرکتوری دانلود",
"downloadDirValid": "لطفا دایرکتوری دانلود را انتخاب کنید",
"connections": "اتصالات",
"items": "@count آیتم ها",
"subscribeTracker": "Subscribe Tracker",
"subscribeFail": "Subscribe failed, please check network or try again later",
"update": "بروزرسانی",
"updateDaily": "بروزرسانی روزانه",
"lastUpdate": "Last update: @time",
"addTracker": "Add Tracker",
"addTrackerHit": "Please enter the tracker server url, one per line",
"ui": "رابط کاربری",
"theme": "پوسته",
"themeSystem": "سیستم",
"themeLight": "روشن",
"themeDark": "تاریک",
"locale": "Language",
"about": "درباره ی",
"homepage": "صفحه اصلی",
"version": "نسخه",
"protocol": "پروتکل",
"port": "پورت",
"apiToken": "API توکن",
"notSet": "NS",
"set": "SET",
"effectAfterRestart": "Effect after restart",
"startAll": "شروع همه",
"pauseAll": "توقف همه",
"deleteTask": "پاک کردن کار",
"deleteTaskTip": "فایل های دانلود شده را نگه دارد",
"delete": "پاک کردن",
"newVersionTitle": "@version عنوان: کشف نسخه جدید",
"newVersionUpdate": "دریافت اکنون",
"newVersionLater": "بعداً"
}
+61
View File
@@ -0,0 +1,61 @@
{
"label": "Русский(Россия)",
"error": "Ошибка",
"tip": "Подсказка",
"confirm": "Подтвердить",
"cancel": "Отмена",
"selectAll": "Выбрать все",
"task": "Задачи",
"downloading": "Загрузка",
"downloaded": "Загружено",
"setting": "Настройки",
"donate": "Пожертвовать",
"exit": "Выход",
"create": "Создать задачу",
"downloadLink": "Ссылка для скачивание",
"downloadLinkValid": "Пожалуйста, введите ссылку для скачивания",
"downloadLinkHit": "Пожалуйста, введите ссылку для скачивания, HTTP/HTTPS/MAGNET supported@append",
"downloadLinkHitDesktop": ", или перетащите сюда торрент-файл",
"download": "Скачать",
"noFileSelected": "Файл не выбран",
"noStoragePermission": "Требуется доступ к хранилищу",
"selectFile": "Выберать файл",
"basic": "Основные параметры",
"advanced": "Расширенные",
"general": "Общие",
"downloadDir": "Папка загрузки",
"downloadDirValid": "Пожалуйста, выберите папку для загрузки",
"connections": "Соединений",
"maxRunning": "Максимальное количество активных задач",
"items": "@подсчет элементов",
"subscribeTracker": "Подпись трекера",
"subscribeFail": "Подписка не удалась, пожалуйста, проверьте сеть или повторите попытку позже",
"update": "Обновить",
"updateDaily": "Обновлять ежедневно",
"lastUpdate": "Последнее обновление: @время",
"addTracker": "Добавить трекер",
"addTrackerHit": "Пожалуйста, введите url трекера, по одному в строке",
"ui": "UI",
"theme": "Тема",
"themeSystem": "Системная",
"themeLight": "Светлая",
"themeDark": "Темная",
"locale": "Язык",
"about": "О приложении",
"homepage": "Домашняя страница",
"version": "Версия",
"protocol": "Протокол",
"port": "Порт",
"apiToken": "API Токен",
"notSet": "Не установлено",
"set": "Установлено",
"effectAfterRestart": "Эффект после перезагрузки",
"startAll": "Запустить все",
"pauseAll": "Приостановить все",
"deleteTask": "Удалить задачу",
"deleteTaskTip": "Сохранить загруженные файлы",
"delete": "Удалить",
"newVersionTitle": "Обнаружена новая версия @version",
"newVersionUpdate": "Скачать сейчас",
"newVersionLater": "позже"
}
+61
View File
@@ -0,0 +1,61 @@
{
"label": "中文(简体)",
"error": "错误",
"tip": "提示",
"confirm": "确认",
"cancel": "取消",
"selectAll": "全选",
"task": "任务",
"downloading": "下载中",
"downloaded": "已完成",
"setting": "设置",
"donate": "打赏",
"exit": "退出",
"create": "创建任务",
"downloadLink": "下载链接",
"downloadLinkValid": "请输入下载链接",
"downloadLinkHit": "请输入下载链接,支持 HTTP/HTTPS/MAGNET@append",
"downloadLinkHitDesktop": ",也可以直接拖拽种子文件到此处",
"download": "下载",
"noFileSelected": "请至少选择一个文件下载",
"noStoragePermission": "需要开启存储权限",
"selectFile": "选择文件",
"basic": "基础",
"advanced": "高级",
"general": "通用",
"downloadDir": "下载目录",
"downloadDirValid": "请选择下载目录",
"connections": "连接数",
"maxRunning": "最大下载数",
"items": "@count 条",
"subscribeTracker": "订阅 Tracker",
"subscribeFail": "订阅失败,请检查网络或稍后重试",
"update": "更新",
"updateDaily": "每天自动更新",
"lastUpdate": "上次更新:@time",
"addTracker": "添加 Tracker",
"addTrackerHit": "请输入 tracker 服务器地址,每行一条",
"ui": "界面",
"theme": "主题",
"themeSystem": "跟随系统",
"themeLight": "明亮主题",
"themeDark": "暗黑主题",
"locale": "语言",
"about": "关于",
"homepage": "主页",
"version": "版本",
"protocol": "通讯协议",
"port": "端口",
"apiToken": "接口令牌",
"notSet": "未设置",
"set": "已设置",
"effectAfterRestart": "此配置项将在重启应用后生效",
"startAll": "全部开始",
"pauseAll": "全部暂停",
"deleteTask": "删除任务",
"deleteTaskTip": "保留已下载的文件",
"delete": "删除",
"newVersionTitle": "发现新版本 @version",
"newVersionUpdate": "立即下载",
"newVersionLater": "稍后再说"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
/* Code generated by cmd/cgo; DO NOT EDIT. */
/* package github.com/monkeyWie/gopeed/bind/desktop */
/* package github.com/GopeedLab/gopeed/bind/desktop */
#line 1 "cgo-builtin-export-prolog"
+65 -30
View File
@@ -1,16 +1,16 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/adapter.dart';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:flutter/foundation.dart';
import 'model/resolve_result.dart';
import '../util/util.dart';
import 'model/create_task.dart';
import 'model/request.dart';
import 'model/resource.dart';
import 'model/result.dart';
import 'model/task.dart';
import '../core/libgopeed_boot.dart';
import 'model/server_config.dart';
import 'model/downloader_config.dart';
class _Client {
static _Client? _instance;
@@ -19,23 +19,38 @@ class _Client {
_Client._internal();
factory _Client() {
factory _Client(String network, String address, String apiToken) {
if (_instance == null) {
_instance = _Client._internal();
var dio = Dio();
final isUnixSocket = LibgopeedBoot.instance.config.network == 'unix';
dio.options.baseUrl = isUnixSocket
? 'http://127.0.0.1'
: 'http://${LibgopeedBoot.instance.config.address}';
final isUnixSocket = network == 'unix';
var baseUrl = 'http://127.0.0.1';
if (!isUnixSocket) {
if (Util.isWeb()) {
baseUrl = kDebugMode ? 'http://127.0.0.1:9999' : '';
} else {
baseUrl = 'http://$address';
}
}
dio.options.baseUrl = baseUrl;
dio.options.contentType = Headers.jsonContentType;
dio.options.connectTimeout = const Duration(seconds: 5);
dio.options.receiveTimeout = const Duration(seconds: 60);
dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) {
if (apiToken.isNotEmpty) {
options.headers['X-Api-Token'] = apiToken;
}
handler.next(options);
}));
_instance!.dio = dio;
if (isUnixSocket) {
(_instance!.dio.httpClientAdapter as DefaultHttpClientAdapter)
(_instance!.dio.httpClientAdapter as IOHttpClientAdapter)
.onHttpClientCreate = (client) {
client.connectionFactory =
(Uri uri, String? proxyHost, int? proxyPort) {
var address = InternetAddress(LibgopeedBoot.instance.config.address,
type: InternetAddressType.unix);
return Socket.startConnect(address, 0);
return Socket.startConnect(
InternetAddress(address, type: InternetAddressType.unix), 0);
};
return client;
};
@@ -45,7 +60,11 @@ class _Client {
}
}
var _client = _Client();
late _Client _client;
void init(String network, String address, String apiToken) {
_client = _Client(network, address, apiToken);
}
Future<T> _parse<T>(
Future<Response> Function() fetch,
@@ -53,28 +72,25 @@ Future<T> _parse<T>(
) async {
try {
var resp = await fetch();
if (fromJsonT != null) {
return Result<T>.fromJson(jsonDecode(resp.data), fromJsonT).data as T;
fromJsonT ??= (json) => null as T;
final result = Result<T>.fromJson(resp.data, fromJsonT);
if (result.code == 0) {
return result.data as T;
} else {
return null as T;
throw Exception(result);
}
} on DioError catch (e) {
if (e.response == null) {
throw Exception("Server error");
}
throw Exception(
Result.fromJson(jsonDecode(e.response?.data), (_) => null).msg);
throw Exception(Result(code: 1000, msg: e.message));
}
}
Future<Resource> resolve(Request request) async {
return _parse<Resource>(
Future<ResolveResult> resolve(Request request) async {
return _parse<ResolveResult>(
() => _client.dio.post("/api/v1/resolve", data: request),
(data) => Resource.fromJson(data));
(data) => ResolveResult.fromJson(data));
}
Future<String> createTask(CreateTask createTask) async {
print(jsonEncode(createTask));
return _parse<String>(
() => _client.dio.post("/api/v1/tasks", data: createTask),
(data) => data as String);
@@ -95,16 +111,35 @@ Future<void> continueTask(String id) async {
return _parse(() => _client.dio.put("/api/v1/tasks/$id/continue"), null);
}
Future<void> pauseAllTasks() async {
return _parse(() => _client.dio.put("/api/v1/tasks/pause"), null);
}
Future<void> continueAllTasks() async {
return _parse(() => _client.dio.put("/api/v1/tasks/continue"), null);
}
Future<void> deleteTask(String id, bool force) async {
return _parse(
() => _client.dio.delete("/api/v1/tasks/$id?force=$force"), null);
}
Future<ServerConfig> getConfig() async {
Future<DownloaderConfig> getConfig() async {
return _parse(() => _client.dio.get("/api/v1/config"),
(data) => ServerConfig.fromJson(data));
(data) => DownloaderConfig.fromJson(data));
}
Future<void> putConfig(ServerConfig config) async {
Future<void> putConfig(DownloaderConfig config) async {
return _parse(() => _client.dio.put("/api/v1/config", data: config), null);
}
Future<Response<String>> proxyRequest<T>(String uri,
{data, Options? options}) async {
options ??= Options();
options.headers ??= {};
options.headers!["X-Target-Uri"] = uri;
// add timestamp to avoid cache
return _client.dio.request("/api/v1/proxy?t=${DateTime.now()}",
data: data, options: options);
}
+8 -4
View File
@@ -1,21 +1,25 @@
import 'package:json_annotation/json_annotation.dart';
import 'options.dart';
import 'resource.dart';
import 'request.dart';
part 'create_task.g.dart';
@JsonSerializable(explicitToJson: true)
class CreateTask {
Resource res;
String? rid;
Request? req;
Options? opts;
CreateTask({
required this.res,
this.rid,
this.req,
this.opts,
});
factory CreateTask.fromJson(Map<String, dynamic> json) =>
factory CreateTask.fromJson(
Map<String, dynamic> json,
) =>
_$CreateTaskFromJson(json);
Map<String, dynamic> toJson() => _$CreateTaskToJson(this);
+7 -4
View File
@@ -7,16 +7,17 @@ part of 'create_task.dart';
// **************************************************************************
CreateTask _$CreateTaskFromJson(Map<String, dynamic> json) => CreateTask(
res: Resource.fromJson(json['res'] as Map<String, dynamic>),
rid: json['rid'] as String?,
req: json['req'] == null
? null
: Request.fromJson(json['req'] as Map<String, dynamic>),
opts: json['opts'] == null
? null
: Options.fromJson(json['opts'] as Map<String, dynamic>),
);
Map<String, dynamic> _$CreateTaskToJson(CreateTask instance) {
final val = <String, dynamic>{
'res': instance.res.toJson(),
};
final val = <String, dynamic>{};
void writeNotNull(String key, dynamic value) {
if (value != null) {
@@ -24,6 +25,8 @@ Map<String, dynamic> _$CreateTaskToJson(CreateTask instance) {
}
}
writeNotNull('rid', instance.rid);
writeNotNull('req', instance.req?.toJson());
writeNotNull('opts', instance.opts?.toJson());
return val;
}
@@ -0,0 +1,79 @@
import 'package:json_annotation/json_annotation.dart';
part 'downloader_config.g.dart';
@JsonSerializable(explicitToJson: true)
class DownloaderConfig {
String downloadDir = '';
int maxRunning = 0;
ProtocolConfig protocolConfig = ProtocolConfig();
ExtraConfig extra = ExtraConfig();
DownloaderConfig();
factory DownloaderConfig.fromJson(Map<String, dynamic> json) =>
_$DownloaderConfigFromJson(json);
Map<String, dynamic> toJson() => _$DownloaderConfigToJson(this);
}
@JsonSerializable(explicitToJson: true)
class ProtocolConfig {
HttpConfig http = HttpConfig();
BtConfig bt = BtConfig();
ProtocolConfig();
factory ProtocolConfig.fromJson(Map<String, dynamic>? json) =>
json == null ? ProtocolConfig() : _$ProtocolConfigFromJson(json);
Map<String, dynamic> toJson() => _$ProtocolConfigToJson(this);
}
@JsonSerializable()
class HttpConfig {
int connections = 0;
HttpConfig();
factory HttpConfig.fromJson(Map<String, dynamic> json) =>
_$HttpConfigFromJson(json);
Map<String, dynamic> toJson() => _$HttpConfigToJson(this);
}
@JsonSerializable()
class BtConfig {
List<String> trackers = [];
BtConfig();
factory BtConfig.fromJson(Map<String, dynamic> json) =>
_$BtConfigFromJson(json);
Map<String, dynamic> toJson() => _$BtConfigToJson(this);
}
@JsonSerializable(explicitToJson: true)
class ExtraConfig {
String themeMode = '';
String locale = '';
ExtraConfigBt bt = ExtraConfigBt();
ExtraConfig();
factory ExtraConfig.fromJson(Map<String, dynamic>? json) =>
json == null ? ExtraConfig() : _$ExtraConfigFromJson(json);
Map<String, dynamic> toJson() => _$ExtraConfigToJson(this);
}
@JsonSerializable()
class ExtraConfigBt {
List<String> trackerSubscribeUrls = [];
List<String> subscribeTrackers = [];
DateTime? lastTrackerUpdateTime;
List<String> customTrackers = [];
ExtraConfigBt();
factory ExtraConfigBt.fromJson(Map<String, dynamic> json) =>
_$ExtraConfigBtFromJson(json);
Map<String, dynamic> toJson() => _$ExtraConfigBtToJson(this);
}
@@ -0,0 +1,95 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'downloader_config.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
DownloaderConfig()
..downloadDir = json['downloadDir'] as String
..maxRunning = json['maxRunning'] as int
..protocolConfig = ProtocolConfig.fromJson(
json['protocolConfig'] as Map<String, dynamic>?)
..extra = ExtraConfig.fromJson(json['extra'] as Map<String, dynamic>?);
Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) =>
<String, dynamic>{
'downloadDir': instance.downloadDir,
'maxRunning': instance.maxRunning,
'protocolConfig': instance.protocolConfig.toJson(),
'extra': instance.extra.toJson(),
};
ProtocolConfig _$ProtocolConfigFromJson(Map<String, dynamic> json) =>
ProtocolConfig()
..http = HttpConfig.fromJson(json['http'] as Map<String, dynamic>)
..bt = BtConfig.fromJson(json['bt'] as Map<String, dynamic>);
Map<String, dynamic> _$ProtocolConfigToJson(ProtocolConfig instance) =>
<String, dynamic>{
'http': instance.http.toJson(),
'bt': instance.bt.toJson(),
};
HttpConfig _$HttpConfigFromJson(Map<String, dynamic> json) =>
HttpConfig()..connections = json['connections'] as int;
Map<String, dynamic> _$HttpConfigToJson(HttpConfig instance) =>
<String, dynamic>{
'connections': instance.connections,
};
BtConfig _$BtConfigFromJson(Map<String, dynamic> json) => BtConfig()
..trackers =
(json['trackers'] as List<dynamic>).map((e) => e as String).toList();
Map<String, dynamic> _$BtConfigToJson(BtConfig instance) => <String, dynamic>{
'trackers': instance.trackers,
};
ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig()
..themeMode = json['themeMode'] as String
..locale = json['locale'] as String
..bt = ExtraConfigBt.fromJson(json['bt'] as Map<String, dynamic>);
Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
<String, dynamic>{
'themeMode': instance.themeMode,
'locale': instance.locale,
'bt': instance.bt.toJson(),
};
ExtraConfigBt _$ExtraConfigBtFromJson(Map<String, dynamic> json) =>
ExtraConfigBt()
..trackerSubscribeUrls = (json['trackerSubscribeUrls'] as List<dynamic>)
.map((e) => e as String)
.toList()
..subscribeTrackers = (json['subscribeTrackers'] as List<dynamic>)
.map((e) => e as String)
.toList()
..lastTrackerUpdateTime = json['lastTrackerUpdateTime'] == null
? null
: DateTime.parse(json['lastTrackerUpdateTime'] as String)
..customTrackers = (json['customTrackers'] as List<dynamic>)
.map((e) => e as String)
.toList();
Map<String, dynamic> _$ExtraConfigBtToJson(ExtraConfigBt instance) {
final val = <String, dynamic>{
'trackerSubscribeUrls': instance.trackerSubscribeUrls,
'subscribeTrackers': instance.subscribeTrackers,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('lastTrackerUpdateTime',
instance.lastTrackerUpdateTime?.toIso8601String());
val['customTrackers'] = instance.customTrackers;
return val;
}
+22
View File
@@ -0,0 +1,22 @@
import 'options.dart';
import 'request.dart';
import 'resource.dart';
import 'package:json_annotation/json_annotation.dart';
part 'meta.g.dart';
@JsonSerializable(explicitToJson: true)
class Meta {
Request req;
Resource res;
Options opts;
Meta({
required this.req,
required this.res,
required this.opts,
});
factory Meta.fromJson(Map<String, dynamic> json) => _$MetaFromJson(json);
Map<String, dynamic> toJson() => _$MetaToJson(this);
}
+19
View File
@@ -0,0 +1,19 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'meta.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Meta _$MetaFromJson(Map<String, dynamic> json) => Meta(
req: Request.fromJson(json['req'] as Map<String, dynamic>),
res: Resource.fromJson(json['res'] as Map<String, dynamic>),
opts: Options.fromJson(json['opts'] as Map<String, dynamic>),
);
Map<String, dynamic> _$MetaToJson(Meta instance) => <String, dynamic>{
'req': instance.req.toJson(),
'res': instance.res.toJson(),
'opts': instance.opts.toJson(),
};
+13 -3
View File
@@ -2,17 +2,16 @@ import 'package:json_annotation/json_annotation.dart';
part 'options.g.dart';
@JsonSerializable()
@JsonSerializable(explicitToJson: true, genericArgumentFactories: true)
class Options {
String name;
String path;
int connections;
List<int> selectFiles;
Map<String, dynamic>? extra;
Options({
required this.name,
required this.path,
required this.connections,
required this.selectFiles,
});
@@ -21,3 +20,14 @@ class Options {
Map<String, dynamic> toJson() => _$OptionsToJson(this);
}
@JsonSerializable()
class OptsExtraHttp {
int connections = 0;
OptsExtraHttp();
factory OptsExtraHttp.fromJson(Map<String, dynamic> json) =>
_$OptsExtraHttpFromJson(json);
Map<String, dynamic> toJson() => _$OptsExtraHttpToJson(this);
}
+23 -6
View File
@@ -9,14 +9,31 @@ part of 'options.dart';
Options _$OptionsFromJson(Map<String, dynamic> json) => Options(
name: json['name'] as String,
path: json['path'] as String,
connections: json['connections'] as int,
selectFiles:
(json['selectFiles'] as List<dynamic>).map((e) => e as int).toList(),
);
)..extra = json['extra'] as Map<String, dynamic>?;
Map<String, dynamic> _$OptionsToJson(Options instance) => <String, dynamic>{
'name': instance.name,
'path': instance.path,
Map<String, dynamic> _$OptionsToJson(Options instance) {
final val = <String, dynamic>{
'name': instance.name,
'path': instance.path,
'selectFiles': instance.selectFiles,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('extra', instance.extra);
return val;
}
OptsExtraHttp _$OptsExtraHttpFromJson(Map<String, dynamic> json) =>
OptsExtraHttp()..connections = json['connections'] as int;
Map<String, dynamic> _$OptsExtraHttpToJson(OptsExtraHttp instance) =>
<String, dynamic>{
'connections': instance.connections,
'selectFiles': instance.selectFiles,
};
+16 -2
View File
@@ -2,17 +2,31 @@ import 'package:json_annotation/json_annotation.dart';
part 'request.g.dart';
@JsonSerializable()
@JsonSerializable(explicitToJson: true)
class Request {
String url;
Map<String, dynamic>? extra;
Request({
required this.url,
this.extra,
});
factory Request.fromJson(Map<String, dynamic> json) =>
_$RequestFromJson(json);
Map<String, dynamic> toJson() => _$RequestToJson(this);
}
@JsonSerializable()
class ReqExtraHttp {
String method = 'GET';
Map<String, String> headers = {};
String body = '';
ReqExtraHttp();
factory ReqExtraHttp.fromJson(Map<String, dynamic> json) =>
_$ReqExtraHttpFromJson(json);
Map<String, dynamic> toJson() => _$ReqExtraHttpToJson(this);
}
+13 -2
View File
@@ -8,8 +8,7 @@ part of 'request.dart';
Request _$RequestFromJson(Map<String, dynamic> json) => Request(
url: json['url'] as String,
extra: json['extra'] as Map<String, dynamic>?,
);
)..extra = json['extra'] as Map<String, dynamic>?;
Map<String, dynamic> _$RequestToJson(Request instance) {
final val = <String, dynamic>{
@@ -25,3 +24,15 @@ Map<String, dynamic> _$RequestToJson(Request instance) {
writeNotNull('extra', instance.extra);
return val;
}
ReqExtraHttp _$ReqExtraHttpFromJson(Map<String, dynamic> json) => ReqExtraHttp()
..method = json['method'] as String
..headers = Map<String, String>.from(json['headers'] as Map)
..body = json['body'] as String;
Map<String, dynamic> _$ReqExtraHttpToJson(ReqExtraHttp instance) =>
<String, dynamic>{
'method': instance.method,
'headers': instance.headers,
'body': instance.body,
};
@@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
import 'resource.dart';
part 'resolve_result.g.dart';
@JsonSerializable(explicitToJson: true)
class ResolveResult {
String id;
Resource res;
ResolveResult({
required this.id,
required this.res,
});
factory ResolveResult.fromJson(Map<String, dynamic> json) =>
_$ResolveResultFromJson(json);
Map<String, dynamic> toJson() => _$ResolveResultToJson(this);
}
@@ -0,0 +1,19 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'resolve_result.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ResolveResult _$ResolveResultFromJson(Map<String, dynamic> json) =>
ResolveResult(
id: json['id'] as String,
res: Resource.fromJson(json['res'] as Map<String, dynamic>),
);
Map<String, dynamic> _$ResolveResultToJson(ResolveResult instance) =>
<String, dynamic>{
'id': instance.id,
'res': instance.res.toJson(),
};
+15 -14
View File
@@ -1,45 +1,46 @@
import 'package:json_annotation/json_annotation.dart';
import 'request.dart';
part 'resource.g.dart';
@JsonSerializable(explicitToJson: true)
class Resource {
Request req;
String name;
int size;
bool range;
String rootDir;
List<FileInfo> files;
Map<String, dynamic>? extra;
String hash;
Resource({
required this.req,
required this.name,
required this.size,
required this.range,
required this.files,
this.extra,
});
Resource(
{required this.name,
required this.size,
required this.range,
required this.rootDir,
required this.files,
required this.hash});
factory Resource.fromJson(Map<String, dynamic> json) =>
_$ResourceFromJson(json);
Map<String, dynamic> toJson() => _$ResourceToJson(this);
}
@JsonSerializable()
class FileInfo {
String name;
// String rootDir;
String path;
String name;
int size;
FileInfo({
required this.name,
// required this.rootDir,
required this.path,
required this.name,
required this.size,
});
factory FileInfo.fromJson(Map<String, dynamic> json) =>
_$FileInfoFromJson(json);
Map<String, dynamic> toJson() => _$FileInfoToJson(this);
}
+12 -22
View File
@@ -7,43 +7,33 @@ part of 'resource.dart';
// **************************************************************************
Resource _$ResourceFromJson(Map<String, dynamic> json) => Resource(
req: Request.fromJson(json['req'] as Map<String, dynamic>),
name: json['name'] as String,
size: json['size'] as int,
range: json['range'] as bool,
rootDir: json['rootDir'] as String,
files: (json['files'] as List<dynamic>)
.map((e) => FileInfo.fromJson(e as Map<String, dynamic>))
.toList(),
extra: json['extra'] as Map<String, dynamic>?,
hash: json['hash'] as String,
);
Map<String, dynamic> _$ResourceToJson(Resource instance) {
final val = <String, dynamic>{
'req': instance.req.toJson(),
'name': instance.name,
'size': instance.size,
'range': instance.range,
'files': instance.files.map((e) => e.toJson()).toList(),
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('extra', instance.extra);
return val;
}
Map<String, dynamic> _$ResourceToJson(Resource instance) => <String, dynamic>{
'name': instance.name,
'size': instance.size,
'range': instance.range,
'rootDir': instance.rootDir,
'files': instance.files.map((e) => e.toJson()).toList(),
'hash': instance.hash,
};
FileInfo _$FileInfoFromJson(Map<String, dynamic> json) => FileInfo(
name: json['name'] as String,
path: json['path'] as String,
name: json['name'] as String,
size: json['size'] as int,
);
Map<String, dynamic> _$FileInfoToJson(FileInfo instance) => <String, dynamic>{
'name': instance.name,
'path': instance.path,
'name': instance.name,
'size': instance.size,
};
+3
View File
@@ -4,10 +4,12 @@ part 'result.g.dart';
@JsonSerializable(genericArgumentFactories: true)
class Result<T> {
int code;
String? msg;
T? data;
Result({
required this.code,
this.msg,
this.data,
});
@@ -18,6 +20,7 @@ class Result<T> {
) =>
_$ResultFromJson(json, fromJsonT);
Map<String, dynamic> toJson() => {
'code': code,
'msg': msg,
'data': data is List
? (data as dynamic)?.map((e) => e.toJson()).toList()
+4 -1
View File
@@ -11,6 +11,7 @@ Result<T> _$ResultFromJson<T>(
T Function(Object? json) fromJsonT,
) =>
Result<T>(
code: json['code'] as int,
msg: json['msg'] as String?,
data: _$nullableGenericFromJson(json['data'], fromJsonT),
);
@@ -19,7 +20,9 @@ Map<String, dynamic> _$ResultToJson<T>(
Result<T> instance,
Object? Function(T value) toJsonT,
) {
final val = <String, dynamic>{};
final val = <String, dynamic>{
'code': instance.code,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
@@ -1,24 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'server_config.g.dart';
@JsonSerializable()
class ServerConfig {
late String host;
late int port;
late int connections;
late String downloadDir;
Map<String, dynamic>? extra;
ServerConfig({
required this.host,
required this.port,
required this.connections,
required this.downloadDir,
this.extra,
});
factory ServerConfig.fromJson(Map<String, dynamic> json) =>
_$ServerConfigFromJson(json);
Map<String, dynamic> toJson() => _$ServerConfigToJson(this);
}
@@ -1,33 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'server_config.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
ServerConfig _$ServerConfigFromJson(Map<String, dynamic> json) => ServerConfig(
host: json['host'] as String,
port: json['port'] as int,
connections: json['connections'] as int,
downloadDir: json['downloadDir'] as String,
extra: json['extra'] as Map<String, dynamic>?,
);
Map<String, dynamic> _$ServerConfigToJson(ServerConfig instance) {
final val = <String, dynamic>{
'host': instance.host,
'port': instance.port,
'connections': instance.connections,
'downloadDir': instance.downloadDir,
};
void writeNotNull(String key, dynamic value) {
if (value != null) {
val[key] = value;
}
}
writeNotNull('extra', instance.extra);
return val;
}
+5 -8
View File
@@ -1,17 +1,15 @@
import 'package:json_annotation/json_annotation.dart';
import 'options.dart';
import 'resource.dart';
import 'meta.dart';
part 'task.g.dart';
enum Status { ready, running, pause, error, done }
enum Status { ready, running, pause, wait, error, done }
@JsonSerializable()
@JsonSerializable(explicitToJson: true)
class Task {
String id;
Resource res;
Options opts;
Meta meta;
Status status;
Progress progress;
int size;
@@ -19,8 +17,7 @@ class Task {
Task({
required this.id,
required this.res,
required this.opts,
required this.meta,
required this.status,
required this.progress,
required this.size,
+4 -5
View File
@@ -8,8 +8,7 @@ part of 'task.dart';
Task _$TaskFromJson(Map<String, dynamic> json) => Task(
id: json['id'] as String,
res: Resource.fromJson(json['res'] as Map<String, dynamic>),
opts: Options.fromJson(json['opts'] as Map<String, dynamic>),
meta: Meta.fromJson(json['meta'] as Map<String, dynamic>),
status: $enumDecode(_$StatusEnumMap, json['status']),
progress: Progress.fromJson(json['progress'] as Map<String, dynamic>),
size: json['size'] as int,
@@ -18,10 +17,9 @@ Task _$TaskFromJson(Map<String, dynamic> json) => Task(
Map<String, dynamic> _$TaskToJson(Task instance) => <String, dynamic>{
'id': instance.id,
'res': instance.res,
'opts': instance.opts,
'meta': instance.meta.toJson(),
'status': _$StatusEnumMap[instance.status]!,
'progress': instance.progress,
'progress': instance.progress.toJson(),
'size': instance.size,
'createdAt': instance.createdAt.toIso8601String(),
};
@@ -30,6 +28,7 @@ const _$StatusEnumMap = {
Status.ready: 'ready',
Status.running: 'running',
Status.pause: 'pause',
Status.wait: 'wait',
Status.error: 'error',
Status.done: 'done',
};
@@ -0,0 +1,8 @@
import 'package:get/get.dart';
class ApiProvider extends GetConnect {
@override
void onInit() {
httpClient.baseUrl = 'YOUR-API-URL';
}
}
@@ -0,0 +1,12 @@
import 'package:get/get.dart';
import '../controllers/app_controller.dart';
class AppBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<AppController>(
() => AppController(),
);
}
}
@@ -0,0 +1,370 @@
import 'dart:async';
import 'dart:convert';
import 'dart:ui' as ui;
import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tray_manager/tray_manager.dart';
import 'package:uri_to_file/uri_to_file.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:window_manager/window_manager.dart';
import '../../../../api/api.dart';
import '../../../../api/model/downloader_config.dart';
import '../../../../core/common/start_config.dart';
import '../../../../generated/locales.g.dart';
import '../../../../util/localeManager.dart';
import '../../../../util/log_util.dart';
import '../../../../util/package_info.dart';
import '../../../../util/util.dart';
import '../../../routes/app_pages.dart';
const _startConfigNetwork = "start.network";
const _startConfigAddress = "start.address";
const _startConfigApiToken = "start.apiToken";
const unixSocketPath = 'gopeed.sock';
const allTrackerSubscribeUrls = [
'https://github.com/ngosang/trackerslist/raw/master/trackers_all.txt',
'https://github.com/ngosang/trackerslist/raw/master/trackers_all_http.txt',
'https://github.com/ngosang/trackerslist/raw/master/trackers_all_https.txt',
'https://github.com/ngosang/trackerslist/raw/master/trackers_all_ip.txt',
'https://github.com/ngosang/trackerslist/raw/master/trackers_all_udp.txt',
'https://github.com/ngosang/trackerslist/raw/master/trackers_all_ws.txt',
'https://github.com/ngosang/trackerslist/raw/master/trackers_best.txt',
'https://github.com/ngosang/trackerslist/raw/master/trackers_best_ip.txt',
'https://github.com/XIU2/TrackersListCollection/raw/master/all.txt',
'https://github.com/XIU2/TrackersListCollection/raw/master/best.txt',
'https://github.com/XIU2/TrackersListCollection/raw/master/http.txt',
];
const allTrackerCdns = [
// jsdelivr: https://cdn.jsdelivr.net/gh/ngosang/trackerslist/trackers_all.txt
["https://cdn.jsdelivr.net/gh", r".*github.com(/.*)/raw/master(/.*)"],
// nuaa: https://hub.nuaa.cf/ngosang/trackerslist/raw/master/trackers_all.txt
["https://hub.nuaa.cf", r".*github.com(/.*)"]
];
final allTrackerSubscribeUrlCdns = Map.fromIterable(allTrackerSubscribeUrls,
key: (v) => v as String,
value: (v) {
final ret = [v as String];
for (final cdn in allTrackerCdns) {
final reg = RegExp(cdn[1]);
final match = reg.firstMatch(v.toString());
var matchStr = "";
for (var i = 1; i <= match!.groupCount; i++) {
matchStr += match.group(i)!;
}
ret.add("${cdn[0]}$matchStr");
}
return ret;
});
class AppController extends GetxController with WindowListener, TrayListener {
static StartConfig? _defaultStartConfig;
final startConfig = StartConfig().obs;
final runningPort = 0.obs;
final downloaderConfig = DownloaderConfig().obs;
late AppLinks _appLinks;
StreamSubscription<Uri>? _linkSubscription;
@override
void onReady() {
super.onReady();
try {
_initDeepLinks();
} catch (e) {
logger.w("initDeepLinks error", e);
}
try {
_initWindows();
} catch (e) {
logger.w("initWindows error", e);
}
try {
_initTray();
} catch (e) {
logger.w("initTray error", e);
}
}
@override
void onClose() {
_linkSubscription?.cancel();
trayManager.removeListener(this);
}
@override
void onWindowClose() async {
final isPreventClose = await windowManager.isPreventClose();
if (isPreventClose) {
windowManager.hide();
}
}
@override
void onTrayIconMouseDown() {
windowManager.show();
}
@override
void onTrayIconRightMouseDown() {
trayManager.popUpContextMenu();
}
Future<void> _initDeepLinks() async {
// currently only support android
if (!Util.isAndroid()) {
return;
}
_appLinks = AppLinks();
// Handle link when app is in warm state (front or background)
_linkSubscription = _appLinks.uriLinkStream.listen((uri) async {
await _toCreate(uri);
});
// Check initial link if app was in cold state (terminated)
final uri = await _appLinks.getInitialAppLink();
if (uri != null) {
await _toCreate(uri);
}
}
Future<void> _initWindows() async {
if (!Util.isDesktop()) {
return;
}
windowManager.addListener(this);
}
Future<void> _initTray() async {
if (!Util.isDesktop()) {
return;
}
if (Util.isWindows()) {
await trayManager.setIcon('assets/tray_icon/icon.ico');
} else if (Util.isMacos()) {
await trayManager.setIcon('assets/tray_icon/icon_mac.png',
isTemplate: true);
} else {
await trayManager.setIcon('assets/tray_icon/icon.png');
}
final menu = Menu(items: [
MenuItem(
label: "create".tr,
onClick: (menuItem) async => {
await windowManager.show(),
await Get.rootDelegate.offAndToNamed(Routes.CREATE),
},
),
MenuItem.separator(),
MenuItem(
label: "startAll".tr,
onClick: (menuItem) async => {continueAllTasks()},
),
MenuItem(
label: "pauseAll".tr,
onClick: (menuItem) async => {pauseAllTasks()},
),
MenuItem(
label: 'setting'.tr,
onClick: (menuItem) async => {
await windowManager.show(),
await Get.rootDelegate.offAndToNamed(Routes.SETTING),
},
),
MenuItem.separator(),
MenuItem(
label: 'donate'.tr,
onClick: (menuItem) => {
launchUrl(
Uri.parse(
"https://github.com/GopeedLab/gopeed/blob/main/.donate/index.md#donate"),
mode: LaunchMode.externalApplication)
},
),
MenuItem(
label: '${"version".tr}(${packageInfo.version})',
),
MenuItem.separator(),
MenuItem(
label: 'exit'.tr,
onClick: (menuItem) => {windowManager.destroy()},
),
]);
await trayManager.setContextMenu(menu);
trayManager.addListener(this);
}
Future<void> _toCreate(Uri uri) async {
final path = uri.scheme == "magnet"
? uri.toString()
: (await toFile(uri.toString())).path;
await Get.rootDelegate.offAndToNamed(Routes.CREATE, arguments: path);
}
String runningAddress() {
if (startConfig.value.network == 'unix') {
return startConfig.value.address;
}
return '${startConfig.value.address.split(':').first}:$runningPort';
}
Future<StartConfig> _initDefaultStartConfig() async {
if (_defaultStartConfig != null) {
return _defaultStartConfig!;
}
_defaultStartConfig = StartConfig();
if (!Util.isUnix()) {
// not support unix socket, use tcp
_defaultStartConfig!.network = "tcp";
_defaultStartConfig!.address = "127.0.0.1:0";
} else {
_defaultStartConfig!.network = "unix";
if (Util.isDesktop()) {
_defaultStartConfig!.address = unixSocketPath;
}
if (Util.isMobile()) {
_defaultStartConfig!.address =
"${(await getTemporaryDirectory()).path}/$unixSocketPath";
}
}
_defaultStartConfig!.apiToken = '';
return _defaultStartConfig!;
}
Future<void> loadStartConfig() async {
final defaultCfg = await _initDefaultStartConfig();
final prefs = await SharedPreferences.getInstance();
startConfig.value.network =
prefs.getString(_startConfigNetwork) ?? defaultCfg.network;
startConfig.value.address =
prefs.getString(_startConfigAddress) ?? defaultCfg.address;
startConfig.value.apiToken =
prefs.getString(_startConfigApiToken) ?? defaultCfg.apiToken;
}
Future<void> loadDownloaderConfig() async {
try {
downloaderConfig.value = await getConfig();
} catch (e) {
logger.w("load downloader config fail", e);
downloaderConfig.value = DownloaderConfig();
}
await _initDownloaderConfig();
}
Future<void> trackerUpdate() async {
final btExtConfig = downloaderConfig.value.extra.bt;
final result = <String>[];
for (var u in btExtConfig.trackerSubscribeUrls) {
var allFail = true;
for (var cdn in allTrackerSubscribeUrlCdns[u]!) {
try {
result.addAll(await _fetchTrackers(cdn));
allFail = false;
break;
} catch (e) {
logger.w("subscribe trackers fail, url: $cdn", e);
}
}
if (allFail) {
throw Exception('subscribe trackers fail, network error');
}
}
btExtConfig.subscribeTrackers.clear();
btExtConfig.subscribeTrackers.addAll(result);
downloaderConfig.update((val) {
val!.extra.bt.lastTrackerUpdateTime = DateTime.now();
});
refreshTrackers();
await saveConfig();
}
refreshTrackers() {
final btConfig = downloaderConfig.value.protocolConfig.bt;
final btExtConfig = downloaderConfig.value.extra.bt;
btConfig.trackers.clear();
btConfig.trackers.addAll(btExtConfig.subscribeTrackers);
btConfig.trackers.addAll(btExtConfig.customTrackers);
// remove duplicate
btConfig.trackers.toSet().toList();
}
Future<void> trackerUpdateOnStart() async {
final btExtConfig = downloaderConfig.value.extra.bt;
final lastUpdateTime = btExtConfig.lastTrackerUpdateTime;
// if last update time is null or more than 1 day, update trackers
if (lastUpdateTime == null ||
lastUpdateTime.difference(DateTime.now()).inDays < 0) {
try {
await trackerUpdate();
} catch (e) {
logger.w("tracker update fail", e);
}
}
}
Future<List<String>> _fetchTrackers(String subscribeUrl) async {
final resp = await proxyRequest(subscribeUrl);
if (resp.statusCode != 200) {
throw Exception(
'Failed to get trackers, status code: ${resp.statusCode}');
}
if (resp.data == null || resp.data!.isEmpty) {
throw Exception('Failed to get trackers, data is null');
}
const ls = LineSplitter();
return ls.convert(resp.data!).where((e) => e.isNotEmpty).toList();
}
_initDownloaderConfig() async {
final config = downloaderConfig.value;
if (config.protocolConfig.http.connections == 0) {
config.protocolConfig.http.connections = 16;
}
final extra = config.extra;
if (extra.themeMode.isEmpty) {
extra.themeMode = ThemeMode.system.name;
}
if (extra.locale.isEmpty) {
final systemLocale = getLocaleKey(ui.window.locale);
extra.locale = AppTranslation.translations.containsKey(systemLocale)
? systemLocale
: getLocaleKey(fallbackLocale);
}
if (extra.bt.trackerSubscribeUrls.isEmpty) {
// default select all tracker subscribe urls
extra.bt.trackerSubscribeUrls.addAll(allTrackerSubscribeUrls);
}
if (config.downloadDir.isEmpty) {
if (Util.isDesktop()) {
config.downloadDir = (await getDownloadsDirectory())?.path ?? "./";
} else if (Util.isAndroid()) {
config.downloadDir = (await getExternalStorageDirectory())?.path ??
(await getApplicationDocumentsDirectory()).path;
return;
} else if (Util.isIOS()) {
config.downloadDir = (await getApplicationDocumentsDirectory()).path;
} else {
config.downloadDir = './';
}
}
}
Future<void> saveConfig() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_startConfigNetwork, startConfig.value.network);
await prefs.setString(_startConfigAddress, startConfig.value.address);
await putConfig(downloaderConfig.value);
}
}
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:get/get.dart';
import '../../../../generated/locales.g.dart';
import '../../../../theme/theme.dart';
import '../../../../util/localeManager.dart';
import '../../../routes/app_pages.dart';
import '../controllers/app_controller.dart';
class AppView extends GetView<AppController> {
const AppView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final config = controller.downloaderConfig.value;
return GetMaterialApp.router(
useInheritedMediaQuery: true,
debugShowCheckedModeBanner: false,
theme: GopeedTheme.light,
darkTheme: GopeedTheme.dark,
themeMode: ThemeMode.values.byName(config.extra.themeMode),
// translations: messages,
translationsKeys: AppTranslation.translations,
locale: toLocale(config.extra.locale),
fallbackLocale: fallbackLocale,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales:
AppTranslation.translations.keys.map((e) => toLocale(e)).toList(),
getPages: AppPages.routes,
);
}
}
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff. Show more