mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-13 11:53:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c2321d686 | ||
|
|
daa8c39dad | ||
|
|
78e23217f9 | ||
|
|
61f62ec39a | ||
|
|
5d76d062ad | ||
|
|
fc489b6b97 | ||
|
|
a5cd53f94c | ||
|
|
847e88881d | ||
|
|
3d20e818e5 | ||
|
|
09f7c88ed3 | ||
|
|
f7b26753c1 | ||
|
|
9704d4af27 | ||
|
|
1907f404ac | ||
|
|
72c5ba7d20 | ||
|
|
5b0254f7c0 | ||
|
|
d36b345f92 | ||
|
|
bc5c818eae | ||
|
|
98bd3305b7 | ||
|
|
3f38848051 | ||
|
|
82d7f8ec81 | ||
|
|
a6981d58e9 | ||
|
|
1b1631b41a | ||
|
|
3eb6df55df | ||
|
|
6de63c3f63 | ||
|
|
969615f353 | ||
|
|
5a88b98314 | ||
|
|
2674b1c39e | ||
|
|
4c90b38247 | ||
|
|
93088b22f1 | ||
|
|
fe90c1d261 | ||
|
|
640e798d70 | ||
|
|
ba2df0ecff | ||
|
|
d61da67456 | ||
|
|
7c696ee6e0 | ||
|
|
7b840b4261 | ||
|
|
a3c1f84c2c | ||
|
|
8d33e1a5b3 | ||
|
|
bad7f1d06c | ||
|
|
af1c8b2719 | ||
|
|
d90af0b9d1 | ||
|
|
6016ee1d24 | ||
|
|
e275fd58bd | ||
|
|
c9626cb389 | ||
|
|
f86a379f93 | ||
|
|
a9eee26be7 | ||
|
|
abd16d5326 | ||
|
|
6756d82ffa | ||
|
|
71c9e6947a | ||
|
|
24db9bb58b | ||
|
|
de0afb1481 | ||
|
|
cb6331c20d | ||
|
|
b0e133b554 | ||
|
|
dbe37befa3 | ||
|
|
0d5c4e8e7f | ||
|
|
5515a1c48c | ||
|
|
3abf6ed933 | ||
|
|
af40e8aa63 | ||
|
|
64c5ece02c | ||
|
|
4ccbc3ebc5 | ||
|
|
14d9642d3c | ||
|
|
2f5fbfb17c | ||
|
|
49b4d99b5e | ||
|
|
9d9f1caaaa | ||
|
|
5bb8541385 | ||
|
|
f7189668fd | ||
|
|
1b36ed1807 | ||
|
|
07dc196c3c |
No files matched your search
+1
-1
@@ -12,4 +12,4 @@
|
|||||||
#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
# github: monkeyWie
|
# github: monkeyWie
|
||||||
ko_fi: gopeed
|
ko_fi: gopeed
|
||||||
custom: https://docs.gopeed.com/donate.html
|
custom: https://gopeed.com/docs/donate
|
||||||
@@ -40,16 +40,24 @@ template: |
|
|||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="2">🪟 Windows</td>
|
<td rowspan="4">🪟 Windows</td>
|
||||||
<td><code>EXE</code></td>
|
<td rowspan="2"><code>EXE</code></td>
|
||||||
<td>amd64</td>
|
<td>amd64</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-amd64.zip">📥</a></td>
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-amd64.zip">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>Portable</code></td>
|
<td>arm64</td>
|
||||||
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-arm64.zip">📥</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td rowspan="2"><code>Portable</code></td>
|
||||||
<td>amd64</td>
|
<td>amd64</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-amd64-portable.zip">📥</a></td>
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-amd64-portable.zip">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>arm64</td>
|
||||||
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-arm64-portable.zip">📥</a></td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="3">🍎 MacOS</td>
|
<td rowspan="3">🍎 MacOS</td>
|
||||||
<td rowspan="3"><code>DMG</code></td>
|
<td rowspan="3"><code>DMG</code></td>
|
||||||
|
|||||||
+108
-25
@@ -14,7 +14,9 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: "1.24"
|
GO_VERSION: "1.24"
|
||||||
FLUTTER_VERSION: "3.24.5"
|
FLUTTER_VERSION: "3.41.2"
|
||||||
|
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
||||||
|
GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -34,19 +36,62 @@ jobs:
|
|||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows' }}
|
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows' }}
|
||||||
runs-on: windows-2022
|
|
||||||
needs: [get-release]
|
needs: [get-release]
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: arm64
|
||||||
|
os: windows-11-arm
|
||||||
|
llvm_ver: "20251202" # Only ARM64
|
||||||
|
flutter_channel: "main"
|
||||||
|
flutter_version: "7e1c8868"
|
||||||
|
- arch: amd64
|
||||||
|
os: windows-2022
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
- name: Enable long paths for flutter main branch checks
|
||||||
|
run: |
|
||||||
|
git config --global core.longpaths true
|
||||||
|
|
||||||
|
# Install LLVM environment only on ARM64
|
||||||
|
- name: Install llvm-mingw-ucrt-aarch64 (ARM64 only)
|
||||||
|
if: matrix.arch == 'arm64'
|
||||||
|
run: |
|
||||||
|
$ver = "${{ matrix.llvm_ver }}"
|
||||||
|
$url = "https://github.com/mstorsjo/llvm-mingw/releases/download/$ver/llvm-mingw-$ver-ucrt-aarch64.zip"
|
||||||
|
$zip = "$env:RUNNER_TEMP\\llvm.zip"
|
||||||
|
$extract = "$env:RUNNER_TEMP\\extract"
|
||||||
|
$target = "C:\\clangarm64"
|
||||||
|
|
||||||
|
curl -L $url -o $zip
|
||||||
|
rm -r -fo $extract,$target -ea Ignore
|
||||||
|
mkdir $extract | Out-Null
|
||||||
|
|
||||||
|
tar -xf $zip -C $extract
|
||||||
|
mv (Get-ChildItem $extract)[0].FullName $target
|
||||||
|
|
||||||
|
$b = "$target\\bin"
|
||||||
|
"CC=$b\\clang.exe" >> $env:GITHUB_ENV
|
||||||
|
"CXX=$b\\clang++.exe" >> $env:GITHUB_ENV
|
||||||
|
"CLANGARM64_BIN=$b" >> $env:GITHUB_ENV
|
||||||
|
"CGO_ENABLED=1" >> $env:GITHUB_ENV
|
||||||
|
"CLANGARM64_ROOT=$target" >> $env:GITHUB_ENV
|
||||||
|
$b >> $env:GITHUB_PATH
|
||||||
|
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
channel: ${{ matrix.flutter_channel || 'stable' }}
|
||||||
|
flutter-version: ${{ matrix.flutter_version || env.FLUTTER_VERSION }}
|
||||||
- name: Build
|
- name: Build
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.get-release.outputs.version }}
|
VERSION: ${{ needs.get-release.outputs.version }}
|
||||||
|
GOARCH: ${{ matrix.arch }}
|
||||||
run: |
|
run: |
|
||||||
Invoke-WebRequest -Uri "https://github.com/jrsoftware/issrc/raw/main/Files/Languages/Unofficial/ChineseSimplified.isl" -OutFile "ChineseSimplified.isl"
|
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\"
|
mv ChineseSimplified.isl "C:\Program Files (x86)\Inno Setup 6\Languages\"
|
||||||
@@ -56,21 +101,30 @@ jobs:
|
|||||||
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater.exe github.com/GopeedLab/gopeed/cmd/updater
|
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater.exe github.com/GopeedLab/gopeed/cmd/updater
|
||||||
cd ui/flutter
|
cd ui/flutter
|
||||||
$TAG = "v$env:VERSION"
|
$TAG = "v$env:VERSION"
|
||||||
flutter build windows --dart-define="UPDATE_CHANNEL=windowsPortable"
|
flutter build windows --dart-define="UPDATE_CHANNEL=windowsPortable" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||||
$mingw = "C:\Program Files\Git\mingw64\bin"
|
|
||||||
$system = "C:\Windows\System32"
|
$system = "C:\Windows\System32"
|
||||||
|
if ("${{ matrix.arch }}" -eq "amd64") {
|
||||||
|
# for amd64
|
||||||
$release = "build\windows\x64\runner\Release\"
|
$release = "build\windows\x64\runner\Release\"
|
||||||
|
$mingw = "C:\Program Files\Git\mingw64\bin"
|
||||||
cp $mingw\libstdc++-6.dll $release
|
cp $mingw\libstdc++-6.dll $release
|
||||||
cp $mingw\libgcc_s_seh-1.dll $release
|
cp $mingw\libgcc_s_seh-1.dll $release
|
||||||
|
} else {
|
||||||
|
# for ARM64
|
||||||
|
$release = "build\windows\arm64\runner\Release\"
|
||||||
|
$mingw = "$env:CLANGARM64_ROOT\aarch64-w64-mingw32\bin"
|
||||||
|
cp $mingw\libc++.dll $release
|
||||||
|
cp $mingw\libunwind.dll $release
|
||||||
|
}
|
||||||
cp $mingw\libwinpthread-1.dll $release
|
cp $mingw\libwinpthread-1.dll $release
|
||||||
cp $system\msvcp140.dll $release
|
cp $system\msvcp140.dll $release
|
||||||
cp $system\vcruntime140.dll $release
|
cp $system\vcruntime140.dll $release
|
||||||
cp $system\vcruntime140_1.dll $release
|
cp $system\vcruntime140_1.dll $release
|
||||||
|
|
||||||
New-Item -Path build\windows\Output -ItemType Directory
|
New-Item -Path build\windows\Output -ItemType Directory
|
||||||
Compress-Archive -Path "$release*" -DestinationPath "build\windows\Output\Gopeed-$TAG-windows-amd64-portable.zip"
|
Compress-Archive -Path "$release*" -DestinationPath "build\windows\Output\Gopeed-$TAG-windows-${{ matrix.arch }}-portable.zip"
|
||||||
|
|
||||||
flutter build windows --dart-define="UPDATE_CHANNEL=windowsInstaller"
|
flutter build windows --dart-define="UPDATE_CHANNEL=windowsInstaller" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||||
cd build/windows
|
cd build/windows
|
||||||
echo @"
|
echo @"
|
||||||
; Script generated by the Inno Setup Script Wizard.
|
; Script generated by the Inno Setup Script Wizard.
|
||||||
@@ -116,7 +170,7 @@ jobs:
|
|||||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||||
|
|
||||||
[Files]
|
[Files]
|
||||||
Source: ".\x64\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs;
|
Source: ".\${{ matrix.arch == 'amd64' && 'x64' || 'arm64' }}\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs;
|
||||||
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
|
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
|
||||||
|
|
||||||
[UninstallDelete]
|
[UninstallDelete]
|
||||||
@@ -130,18 +184,17 @@ jobs:
|
|||||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||||
"@ > setup.iss
|
"@ > setup.iss
|
||||||
iscc.exe setup.iss
|
iscc.exe setup.iss
|
||||||
mv "Output\gopeed.exe" "Output\Gopeed-$TAG-windows-amd64.exe"
|
mv "Output\gopeed.exe" "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe"
|
||||||
Compress-Archive -Path "Output\Gopeed-$TAG-windows-amd64.exe" -DestinationPath "Output\Gopeed-$TAG-windows-amd64.zip"
|
Compress-Archive -Path "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe" -DestinationPath "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.zip"
|
||||||
Remove-Item -Path "Output\Gopeed-$TAG-windows-amd64.exe"
|
|
||||||
- name: Upload
|
- name: Upload
|
||||||
uses: shogo82148/actions-upload-release-asset@v1
|
uses: shogo82148/actions-upload-release-asset@v1.9.2
|
||||||
with:
|
with:
|
||||||
upload_url: ${{ needs.get-release.outputs.upload_url }}
|
upload_url: ${{ needs.get-release.outputs.upload_url }}
|
||||||
asset_path: ui/flutter/build/windows/Output/*
|
asset_path: ui/flutter/build/windows/Output/*
|
||||||
overwrite: true
|
overwrite: true
|
||||||
build-macos-amd64-lib:
|
build-macos-amd64-lib:
|
||||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }}
|
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }}
|
||||||
runs-on: macos-13
|
runs-on: macos-15-intel
|
||||||
needs: [get-release]
|
needs: [get-release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
@@ -226,7 +279,7 @@ jobs:
|
|||||||
VERSION: ${{ needs.get-release.outputs.version }}
|
VERSION: ${{ needs.get-release.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
cd ui/flutter
|
cd ui/flutter
|
||||||
flutter build macos --dart-define="UPDATE_CHANNEL=macosDmg"
|
flutter build macos --dart-define="UPDATE_CHANNEL=macosDmg" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||||
cd build/macos/Build/Products/Release
|
cd build/macos/Build/Products/Release
|
||||||
cat>appdmg.json<<EOF
|
cat>appdmg.json<<EOF
|
||||||
{
|
{
|
||||||
@@ -271,10 +324,7 @@ jobs:
|
|||||||
channel: master
|
channel: master
|
||||||
- run: |
|
- run: |
|
||||||
sudo apt update -y
|
sudo apt update -y
|
||||||
# flutter build dependencies
|
sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate libkeybinder-3.0-dev
|
||||||
sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev
|
|
||||||
# appimage build dependencies
|
|
||||||
sudo apt install -y libfuse2 locate
|
|
||||||
arch=$(uname -m)
|
arch=$(uname -m)
|
||||||
wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
|
wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
|
||||||
chmod +x appimagetool
|
chmod +x appimagetool
|
||||||
@@ -288,6 +338,22 @@ jobs:
|
|||||||
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater
|
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater
|
||||||
|
|
||||||
cd ui/flutter
|
cd ui/flutter
|
||||||
|
|
||||||
|
RPM_ARCH=$(uname -m)
|
||||||
|
mkdir -p linux/packaging/rpm
|
||||||
|
cat << EOF > linux/packaging/rpm/make_config.yaml
|
||||||
|
display_name: Gopeed
|
||||||
|
icon: assets/icon/icon.svg
|
||||||
|
summary: High speed downloader.
|
||||||
|
description: A high speed downloader that supports all platforms.
|
||||||
|
group: Applications/Internet
|
||||||
|
license: GPL-3.0
|
||||||
|
url: https://github.com/GopeedLab/gopeed
|
||||||
|
vendor: GopeedLab
|
||||||
|
maintainer: GopeedLab <[email protected]>
|
||||||
|
build_arch: $RPM_ARCH
|
||||||
|
EOF
|
||||||
|
|
||||||
cat << EOF > distribute_options.yaml
|
cat << EOF > distribute_options.yaml
|
||||||
output: dist/
|
output: dist/
|
||||||
releases:
|
releases:
|
||||||
@@ -300,6 +366,8 @@ jobs:
|
|||||||
build_args:
|
build_args:
|
||||||
dart-define:
|
dart-define:
|
||||||
UPDATE_CHANNEL: linuxAppImage
|
UPDATE_CHANNEL: linuxAppImage
|
||||||
|
GA4_MEASUREMENT_ID: ${{ env.GA4_MEASUREMENT_ID }}
|
||||||
|
GA4_API_SECRET: ${{ env.GA4_API_SECRET }}
|
||||||
- name: deb
|
- name: deb
|
||||||
package:
|
package:
|
||||||
platform: linux
|
platform: linux
|
||||||
@@ -307,12 +375,22 @@ jobs:
|
|||||||
build_args:
|
build_args:
|
||||||
dart-define:
|
dart-define:
|
||||||
UPDATE_CHANNEL: linuxDeb
|
UPDATE_CHANNEL: linuxDeb
|
||||||
|
GA4_MEASUREMENT_ID: ${{ env.GA4_MEASUREMENT_ID }}
|
||||||
|
GA4_API_SECRET: ${{ env.GA4_API_SECRET }}
|
||||||
|
- name: rpm
|
||||||
|
package:
|
||||||
|
platform: linux
|
||||||
|
target: rpm
|
||||||
|
build_args:
|
||||||
|
dart-define:
|
||||||
|
UPDATE_CHANNEL: linuxRpm
|
||||||
|
GA4_MEASUREMENT_ID: ${{ env.GA4_MEASUREMENT_ID }}
|
||||||
|
GA4_API_SECRET: ${{ env.GA4_API_SECRET }}
|
||||||
EOF
|
EOF
|
||||||
dart pub global activate -sgit https://github.com/GopeedLab/flutter_distributor.git --git-path packages/flutter_distributor
|
dart pub global activate -sgit https://github.com/GopeedLab/flutter_distributor.git --git-path packages/flutter_distributor
|
||||||
flutter_distributor release --name linux
|
flutter_distributor release --name linux
|
||||||
cd dist/*
|
cd dist/*
|
||||||
|
|
||||||
# Get current running architecture, x86_64 -> amd64 and aarch64 -> arm64
|
|
||||||
ARCH="amd64"
|
ARCH="amd64"
|
||||||
if [[ "${{ matrix.os }}" == *-arm ]]; then
|
if [[ "${{ matrix.os }}" == *-arm ]]; then
|
||||||
ARCH="arm64"
|
ARCH="arm64"
|
||||||
@@ -320,6 +398,7 @@ jobs:
|
|||||||
|
|
||||||
mv gopeed-*-linux.AppImage Gopeed-v${VERSION}-linux-${ARCH}.AppImage
|
mv gopeed-*-linux.AppImage Gopeed-v${VERSION}-linux-${ARCH}.AppImage
|
||||||
mv gopeed-*-linux.deb Gopeed-v${VERSION}-linux-${ARCH}.deb
|
mv gopeed-*-linux.deb Gopeed-v${VERSION}-linux-${ARCH}.deb
|
||||||
|
mv gopeed-*-linux.rpm Gopeed-v${VERSION}-linux-${ARCH}.rpm
|
||||||
- name: Upload
|
- name: Upload
|
||||||
uses: shogo82148/actions-upload-release-asset@v1
|
uses: shogo82148/actions-upload-release-asset@v1
|
||||||
with:
|
with:
|
||||||
@@ -401,6 +480,7 @@ jobs:
|
|||||||
- cmake
|
- cmake
|
||||||
- curl
|
- curl
|
||||||
- libgtk-3-dev
|
- libgtk-3-dev
|
||||||
|
- libkeybinder-3.0-dev
|
||||||
- ninja-build
|
- ninja-build
|
||||||
- unzip
|
- unzip
|
||||||
- xz-utils
|
- xz-utils
|
||||||
@@ -416,13 +496,14 @@ jobs:
|
|||||||
plugin: nil
|
plugin: nil
|
||||||
stage-packages:
|
stage-packages:
|
||||||
- libgtk-3-dev
|
- libgtk-3-dev
|
||||||
|
- libkeybinder-3.0-dev
|
||||||
- libappindicator3-dev
|
- libappindicator3-dev
|
||||||
override-build: |
|
override-build: |
|
||||||
# work around pub get stack overflow # https://github.com/dart-lang/sdk/issues/51068#issuecomment-1396588253
|
# work around pub get stack overflow # https://github.com/dart-lang/sdk/issues/51068#issuecomment-1396588253
|
||||||
set +e
|
set +e
|
||||||
dart pub get
|
dart pub get
|
||||||
set -eux
|
set -eux
|
||||||
flutter build linux --dart-define="UPDATE_CHANNEL=linuxSnap"
|
flutter build linux --dart-define="UPDATE_CHANNEL=linuxSnap" --dart-define="GA4_MEASUREMENT_ID=$GA4_MEASUREMENT_ID" --dart-define="GA4_API_SECRET=$GA4_API_SECRET"
|
||||||
mkdir -p \$CRAFT_PART_INSTALL/bin/
|
mkdir -p \$CRAFT_PART_INSTALL/bin/
|
||||||
cp -r build/linux/*/release/bundle/* \$CRAFT_PART_INSTALL/bin/
|
cp -r build/linux/*/release/bundle/* \$CRAFT_PART_INSTALL/bin/
|
||||||
gpu-2404:
|
gpu-2404:
|
||||||
@@ -487,8 +568,8 @@ jobs:
|
|||||||
gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0 -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
|
gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0 -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
|
||||||
cd ui/flutter
|
cd ui/flutter
|
||||||
echo $APK_KEYSTORE | base64 -di > android/app/upload-keystore.jks
|
echo $APK_KEYSTORE | base64 -di > android/app/upload-keystore.jks
|
||||||
flutter build apk --dart-define="UPDATE_CHANNEL=androidApk"
|
flutter build apk --dart-define="UPDATE_CHANNEL=androidApk" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||||
flutter build apk --split-per-abi --dart-define="UPDATE_CHANNEL=androidApk"
|
flutter build apk --split-per-abi --dart-define="UPDATE_CHANNEL=androidApk" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||||
mkdir dist
|
mkdir dist
|
||||||
cp build/app/outputs/flutter-apk/app-release.apk dist/Gopeed-v$VERSION-android.apk
|
cp build/app/outputs/flutter-apk/app-release.apk dist/Gopeed-v$VERSION-android.apk
|
||||||
cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk dist/Gopeed-v$VERSION-android-armeabi-v7a.apk
|
cp build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk dist/Gopeed-v$VERSION-android-armeabi-v7a.apk
|
||||||
@@ -521,7 +602,7 @@ jobs:
|
|||||||
gomobile init
|
gomobile init
|
||||||
gomobile bind -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile
|
gomobile bind -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -o ui/flutter/ios/Frameworks/Libgopeed.xcframework -target=ios github.com/GopeedLab/gopeed/bind/mobile
|
||||||
cd ui/flutter
|
cd ui/flutter
|
||||||
flutter build ios --no-codesign --dart-define="UPDATE_CHANNEL=iosIpa"
|
flutter build ios --no-codesign --dart-define="UPDATE_CHANNEL=iosIpa" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||||
mkdir Payload
|
mkdir Payload
|
||||||
cp -r build/ios/iphoneos/Runner.app Payload
|
cp -r build/ios/iphoneos/Runner.app Payload
|
||||||
zip -r -y Payload.zip Payload/Runner.app
|
zip -r -y Payload.zip Payload/Runner.app
|
||||||
@@ -550,7 +631,8 @@ jobs:
|
|||||||
VERSION: ${{ needs.get-release.outputs.version }}
|
VERSION: ${{ needs.get-release.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
cd ui/flutter
|
cd ui/flutter
|
||||||
flutter build web --no-web-resources-cdn
|
flutter build web --no-web-resources-cdn --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||||
|
dart ../../.github/workflows/scripts/flutter_local_font.dart
|
||||||
cd ../../
|
cd ../../
|
||||||
cp -r ui/flutter/build/web cmd/web/dist
|
cp -r ui/flutter/build/web cmd/web/dist
|
||||||
mkdir -p dist/zip
|
mkdir -p dist/zip
|
||||||
@@ -666,7 +748,8 @@ jobs:
|
|||||||
- name: Build flutter web
|
- name: Build flutter web
|
||||||
run: |
|
run: |
|
||||||
cd ui/flutter
|
cd ui/flutter
|
||||||
flutter build web --no-web-resources-cdn --dart-define="UPDATE_CHANNEL=docker"
|
flutter build web --no-web-resources-cdn --dart-define="UPDATE_CHANNEL=docker" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
||||||
|
dart ../../.github/workflows/scripts/flutter_local_font.dart
|
||||||
cd ../../
|
cd ../../
|
||||||
rm -rf cmd/web/dist
|
rm -rf cmd/web/dist
|
||||||
cp -r ui/flutter/build/web cmd/web/dist
|
cp -r ui/flutter/build/web cmd/web/dist
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
// Localize external Google Fonts (fonts.gstatic.com) references in Flutter Web build output.
|
||||||
|
//
|
||||||
|
// Usage (post-build):
|
||||||
|
// # Run AFTER: flutter build web --no-web-resources-cdn
|
||||||
|
// dart ../../.github/workflows/scripts/flutter_local_font.dart
|
||||||
|
//
|
||||||
|
// What it does:
|
||||||
|
// In main.dart.js:
|
||||||
|
// - Find any referenced resources under: https://fonts.gstatic.com/s/
|
||||||
|
// (including the common pattern where Flutter concatenates the base URL with a relative path)
|
||||||
|
// - Download them into: build/web/assets/gstatic/<path>
|
||||||
|
// - Rewrite "https://fonts.gstatic.com/s/" -> "assets/gstatic/" so runtime loads locally
|
||||||
|
// - Only download font subsets needed by supported locales (parsed from message.dart)
|
||||||
|
//
|
||||||
|
// Notes:
|
||||||
|
// - This script DOES NOT call `flutter build web`. It only patches the output.
|
||||||
|
// - This is a best-effort post-process. Always verify in browser devtools.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
/// Get font families required for a specific locale based on language/script.
|
||||||
|
/// Uses language code prefix to automatically detect the script system.
|
||||||
|
/// Returns empty set if base fonts (Latin/Cyrillic/Greek) are sufficient.
|
||||||
|
Set<String> _getFontFamiliesForLocale(String locale) {
|
||||||
|
final lang = locale.toLowerCase().split('_').first;
|
||||||
|
|
||||||
|
// CJK languages - need specific large font files
|
||||||
|
if (lang == 'zh') {
|
||||||
|
// Simplified vs Traditional Chinese
|
||||||
|
if (locale.contains('cn') || locale.contains('sg')) {
|
||||||
|
return {'notosanssc'}; // Simplified Chinese
|
||||||
|
}
|
||||||
|
return {'notosanstc', 'notosanshk'}; // Traditional Chinese (TW, HK, MO)
|
||||||
|
}
|
||||||
|
if (lang == 'ja') return {'notosansjp'}; // Japanese
|
||||||
|
if (lang == 'ko') return {'notosanskr'}; // Korean
|
||||||
|
|
||||||
|
// Arabic script languages
|
||||||
|
if (lang == 'ar') return {'notosansarabic'}; // Arabic
|
||||||
|
if (lang == 'fa') return {'notosansarabic'}; // Persian/Farsi
|
||||||
|
if (lang == 'ur') return {'notosansarabic'}; // Urdu
|
||||||
|
if (lang == 'ps') return {'notosansarabic'}; // Pashto
|
||||||
|
if (lang == 'ku') return {'notosansarabic'}; // Kurdish (Arabic script)
|
||||||
|
|
||||||
|
// Hebrew script
|
||||||
|
if (lang == 'he' || lang == 'yi') return {'notosanshebrew'};
|
||||||
|
|
||||||
|
// South Asian scripts
|
||||||
|
if (lang == 'hi' || lang == 'mr' || lang == 'ne' || lang == 'sa') {
|
||||||
|
return {'notosansdevanagari'}; // Hindi, Marathi, Nepali, Sanskrit
|
||||||
|
}
|
||||||
|
if (lang == 'bn' || lang == 'as')
|
||||||
|
return {'notosansbengali'}; // Bengali, Assamese
|
||||||
|
if (lang == 'ta')
|
||||||
|
return {'notosanstamil', 'notosanstamilsupplement'}; // Tamil
|
||||||
|
if (lang == 'te') return {'notosanstelugu'}; // Telugu
|
||||||
|
if (lang == 'kn') return {'notosanskannada'}; // Kannada
|
||||||
|
if (lang == 'ml') return {'notosansmalayalam'}; // Malayalam
|
||||||
|
if (lang == 'gu') return {'notosansgujarati'}; // Gujarati
|
||||||
|
if (lang == 'pa') return {'notosansgurmukhi'}; // Punjabi (Gurmukhi)
|
||||||
|
if (lang == 'or') return {'notosansoriya'}; // Odia/Oriya
|
||||||
|
if (lang == 'si') return {'notosanssinhala'}; // Sinhala
|
||||||
|
|
||||||
|
// Southeast Asian scripts
|
||||||
|
if (lang == 'th') return {'notosansthai'}; // Thai
|
||||||
|
if (lang == 'lo') return {'notosanslao'}; // Lao
|
||||||
|
if (lang == 'my') return {'notosansmyanmar'}; // Myanmar/Burmese
|
||||||
|
if (lang == 'km') return {'notosanskhmer'}; // Khmer/Cambodian
|
||||||
|
if (lang == 'jv') return {'notosansjavanese'}; // Javanese
|
||||||
|
|
||||||
|
// Other scripts
|
||||||
|
if (lang == 'ka') return {'notosansgeorgian'}; // Georgian
|
||||||
|
if (lang == 'hy') return {'notosansarmenian'}; // Armenian
|
||||||
|
if (lang == 'am' || lang == 'ti')
|
||||||
|
return {'notosansethiopic'}; // Amharic, Tigrinya
|
||||||
|
if (lang == 'mn') return {'notosansmongolian'}; // Mongolian
|
||||||
|
|
||||||
|
// Latin/Cyrillic/Greek based languages - base notosans is sufficient
|
||||||
|
// Includes: English, German, French, Spanish, Italian, Portuguese,
|
||||||
|
// Russian, Ukrainian, Polish, Turkish, Vietnamese, Greek, etc.
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base font families that are always included regardless of locale.
|
||||||
|
/// These provide core functionality and are relatively small.
|
||||||
|
const _baseFontFamilies = <String>{
|
||||||
|
'notosans', // Base Latin/Cyrillic/Greek/Vietnamese
|
||||||
|
'roboto', // Material Design default font
|
||||||
|
'notosanssymbols', // Common symbols
|
||||||
|
'notosanssymbols2', // Additional symbols
|
||||||
|
'notosansmath', // Math symbols
|
||||||
|
'notomusic', // Music notation symbols
|
||||||
|
// Note: notocoloremoji (~24MB) and notoemoji (~860KB) are excluded
|
||||||
|
// to reduce bundle size. Add them back if emoji support is needed.
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Parse supported locales from message.dart file.
|
||||||
|
/// Reads the import statements and extracts locale codes like 'zh_cn', 'en_us', etc.
|
||||||
|
Set<String> _parseSupportedLocales(File messageFile) {
|
||||||
|
final locales = <String>{};
|
||||||
|
|
||||||
|
if (!messageFile.existsSync()) {
|
||||||
|
_fail('Warning: message.dart not found, only base fonts will be used');
|
||||||
|
}
|
||||||
|
|
||||||
|
final content = messageFile.readAsStringSync();
|
||||||
|
|
||||||
|
// Match import statements like: import 'langs/zh_cn.dart';
|
||||||
|
final importRegex = RegExp(r"import\s+'langs/(\w+)\.dart'");
|
||||||
|
for (final match in importRegex.allMatches(content)) {
|
||||||
|
final locale = match.group(1);
|
||||||
|
if (locale != null) {
|
||||||
|
locales.add(locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locales.isEmpty) {
|
||||||
|
_fail(
|
||||||
|
'Warning: No locales found in message.dart, only base fonts will be used');
|
||||||
|
}
|
||||||
|
|
||||||
|
return locales;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get required font families for the given locales.
|
||||||
|
/// Returns a set of font family names that should be included.
|
||||||
|
/// Automatically detects required fonts based on language code prefix.
|
||||||
|
Set<String> _getRequiredFontFamilies(Set<String> locales) {
|
||||||
|
final families = <String>{..._baseFontFamilies};
|
||||||
|
for (final locale in locales) {
|
||||||
|
final localeFamilies = _getFontFamiliesForLocale(locale);
|
||||||
|
families.addAll(localeFamilies);
|
||||||
|
}
|
||||||
|
return families;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a font file path should be included based on required font families.
|
||||||
|
/// Font paths have the format: "fontfamily/version/filename.ext"
|
||||||
|
/// Example: "notosanssc/v36/xxx.ttf" -> font family is "notosanssc"
|
||||||
|
bool _fontMatchesRequiredFamilies(
|
||||||
|
String fontPath, Set<String> requiredFamilies) {
|
||||||
|
// Extract font family from path (first segment before '/')
|
||||||
|
final slashIndex = fontPath.indexOf('/');
|
||||||
|
if (slashIndex == -1) {
|
||||||
|
// No slash found, include by default (unusual path format)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final fontFamily = fontPath.substring(0, slashIndex).toLowerCase();
|
||||||
|
return requiredFamilies.contains(fontFamily);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> main(List<String> args) async {
|
||||||
|
try {
|
||||||
|
// This script is intentionally argument-free for CI convenience.
|
||||||
|
// It assumes it is executed from the Flutter project directory (ui/flutter),
|
||||||
|
// but will also try to locate pubspec.yaml by walking up.
|
||||||
|
final flutterDir = _findFlutterProjectRoot(Directory.current);
|
||||||
|
|
||||||
|
// Parse supported locales from message.dart
|
||||||
|
final messageFile =
|
||||||
|
File(_join(flutterDir.path, 'lib', 'i18n', 'message.dart'));
|
||||||
|
final supportedLocales = _parseSupportedLocales(messageFile);
|
||||||
|
stdout.writeln('Supported locales: ${supportedLocales.join(', ')}');
|
||||||
|
|
||||||
|
// Get required font families based on supported locales
|
||||||
|
final requiredFamilies = _getRequiredFontFamilies(supportedLocales);
|
||||||
|
stdout.writeln('Required font families: ${requiredFamilies.join(', ')}');
|
||||||
|
|
||||||
|
final webDir = Directory(_join(flutterDir.path, 'build', 'web'));
|
||||||
|
if (!webDir.existsSync()) {
|
||||||
|
_fail(
|
||||||
|
'Web build directory not found: ${webDir.path}\n'
|
||||||
|
'Did you run: flutter build web --no-web-resources-cdn ?',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final mainJs = File(_join(webDir.path, 'main.dart.js'));
|
||||||
|
if (!mainJs.existsSync()) {
|
||||||
|
_fail('main.dart.js not found at: ${mainJs.path}');
|
||||||
|
}
|
||||||
|
|
||||||
|
final gstaticRoot = Directory(_join(webDir.path, 'assets', 'gstatic'));
|
||||||
|
if (!gstaticRoot.existsSync()) gstaticRoot.createSync(recursive: true);
|
||||||
|
|
||||||
|
const gstaticSPrefix = 'https://fonts.gstatic.com/s/';
|
||||||
|
final original = mainJs.readAsStringSync();
|
||||||
|
|
||||||
|
// Relative font asset paths which Flutter's loader typically concatenates with:
|
||||||
|
// https://fonts.gstatic.com/s/
|
||||||
|
// Keep this as a best-effort heuristic to cover the common concatenation pattern.
|
||||||
|
// Some gstatic assets include extra dot segments like: notocoloremoji/v32/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFabsE4tq3luCC7p-aXxcn.0.woff2
|
||||||
|
// Allow dots in the path, while still restricting to font-like extensions.
|
||||||
|
final relAssetRegex = RegExp(
|
||||||
|
r"""["']([a-zA-Z0-9/_\.-]+\.(?:woff2|woff|ttf|otf|eot|svg))["']""",
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
final relAssetsUnderS = <String>{};
|
||||||
|
for (final m in relAssetRegex.allMatches(original)) {
|
||||||
|
final p = m.group(1);
|
||||||
|
if (p == null || p.isEmpty) continue;
|
||||||
|
if (p.contains('://') || p.startsWith('data:')) continue;
|
||||||
|
if (p.startsWith('/') ||
|
||||||
|
p.startsWith('assets/') ||
|
||||||
|
p.startsWith('packages/')) continue;
|
||||||
|
if (p.contains('..')) continue;
|
||||||
|
relAssetsUnderS.add(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relAssetsUnderS.isEmpty) {
|
||||||
|
_fail('No fonts.gstatic.com assets found in main.dart.js.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a download plan: dest relative path (under assets/gstatic/) -> URL.
|
||||||
|
// Filter fonts based on required font families to reduce package size.
|
||||||
|
final downloads = <String, Uri>{};
|
||||||
|
final skippedFonts = <String, Set<String>>{};
|
||||||
|
|
||||||
|
for (final rel in relAssetsUnderS) {
|
||||||
|
if (_fontMatchesRequiredFamilies(rel, requiredFamilies)) {
|
||||||
|
downloads[rel] = Uri.parse('$gstaticSPrefix$rel');
|
||||||
|
} else {
|
||||||
|
// Track skipped fonts and their paths for fallback generation
|
||||||
|
final slashIndex = rel.indexOf('/');
|
||||||
|
final family = slashIndex > 0 ? rel.substring(0, slashIndex) : rel;
|
||||||
|
skippedFonts.putIfAbsent(family, () => <String>{}).add(rel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skippedFonts.isNotEmpty) {
|
||||||
|
stdout.writeln(
|
||||||
|
'Skipped ${skippedFonts.length} font families not required by supported locales:',
|
||||||
|
);
|
||||||
|
stdout.writeln(' ${skippedFonts.keys.join(', ')}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (downloads.isNotEmpty) {
|
||||||
|
stdout.writeln(
|
||||||
|
'Found ${downloads.length} fonts.gstatic.com assets to download...',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reuse a single HttpClient to avoid creating hundreds of short-lived
|
||||||
|
// connections (can be flaky on some environments).
|
||||||
|
final httpClient = HttpClient()
|
||||||
|
..connectionTimeout = const Duration(seconds: 30)
|
||||||
|
..idleTimeout = const Duration(seconds: 30)
|
||||||
|
..maxConnectionsPerHost = 6;
|
||||||
|
try {
|
||||||
|
for (final entry in downloads.entries) {
|
||||||
|
final relPath = entry.key;
|
||||||
|
final url = entry.value;
|
||||||
|
final destPath = relPath.replaceAll('/', Platform.pathSeparator);
|
||||||
|
final dest = File(_join(gstaticRoot.path, destPath));
|
||||||
|
await _downloadIfMissing(httpClient, url, dest);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
httpClient.close(force: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate empty fallback font files for skipped fonts to prevent infinite loading
|
||||||
|
// Empty files will cause browsers to fail fast instead of retrying indefinitely
|
||||||
|
if (skippedFonts.isNotEmpty) {
|
||||||
|
var fallbackCount = 0;
|
||||||
|
for (final entry in skippedFonts.entries) {
|
||||||
|
for (final relPath in entry.value) {
|
||||||
|
final destPath = relPath.replaceAll('/', Platform.pathSeparator);
|
||||||
|
final dest = File(_join(gstaticRoot.path, destPath));
|
||||||
|
if (!dest.existsSync()) {
|
||||||
|
dest.parent.createSync(recursive: true);
|
||||||
|
// Create an empty file - browsers will recognize it as invalid and skip
|
||||||
|
dest.writeAsBytesSync([]);
|
||||||
|
fallbackCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fallbackCount > 0) {
|
||||||
|
stdout.writeln(
|
||||||
|
'Generated $fallbackCount empty fallback font files to prevent infinite loading',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite remote prefix so requests become:
|
||||||
|
// <origin>/assets/gstatic/<...>.woff2
|
||||||
|
final replacedGstatic = original.replaceAll(
|
||||||
|
gstaticSPrefix,
|
||||||
|
'assets/gstatic/',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (replacedGstatic == original && downloads.isEmpty) {
|
||||||
|
stdout.writeln(
|
||||||
|
'No changes applied (no fonts.gstatic.com references found).',
|
||||||
|
);
|
||||||
|
exitCode = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainJs.writeAsStringSync(replacedGstatic);
|
||||||
|
|
||||||
|
stdout.writeln('Patched: ${mainJs.path}');
|
||||||
|
if (downloads.isNotEmpty) {
|
||||||
|
stdout.writeln(
|
||||||
|
' - downloaded ${downloads.length} files into: ${gstaticRoot.path}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
stdout.writeln(' - fonts.gstatic.com rewritten -> assets/gstatic/');
|
||||||
|
} catch (e, st) {
|
||||||
|
stderr.writeln('ERROR: $e');
|
||||||
|
stderr.writeln(st);
|
||||||
|
exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _downloadIfMissing(HttpClient client, Uri url, File dest) async {
|
||||||
|
if (dest.existsSync() && dest.lengthSync() > 0) return;
|
||||||
|
|
||||||
|
dest.parent.createSync(recursive: true);
|
||||||
|
|
||||||
|
// Network can be flaky in CI. Retry a few times on transient errors.
|
||||||
|
const maxAttempts = 4;
|
||||||
|
|
||||||
|
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
final tmp = File('${dest.path}.tmp');
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
final req = await client.getUrl(url);
|
||||||
|
|
||||||
|
final resp = await req.close().timeout(const Duration(seconds: 60));
|
||||||
|
if (resp.statusCode != 200) {
|
||||||
|
// 404 is not transient in practice - fail fast with a clear message.
|
||||||
|
if (resp.statusCode == 404) {
|
||||||
|
_fail('Missing gstatic asset (404): $url');
|
||||||
|
}
|
||||||
|
throw HttpException('HTTP ${resp.statusCode}', uri: url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream to temp file first, then rename to avoid leaving partial files.
|
||||||
|
if (tmp.existsSync()) tmp.deleteSync();
|
||||||
|
final sink = tmp.openWrite();
|
||||||
|
await resp.pipe(sink).timeout(const Duration(seconds: 120));
|
||||||
|
|
||||||
|
if (!tmp.existsSync() || tmp.lengthSync() == 0) {
|
||||||
|
throw const FormatException('Downloaded asset is empty');
|
||||||
|
}
|
||||||
|
if (dest.existsSync()) dest.deleteSync();
|
||||||
|
tmp.renameSync(dest.path);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
// no-op: HttpClient lifecycle managed by the caller
|
||||||
|
}
|
||||||
|
} on TimeoutException {
|
||||||
|
if (tmp.existsSync()) tmp.deleteSync();
|
||||||
|
if (attempt == maxAttempts) {
|
||||||
|
_fail('Timeout downloading asset: $url');
|
||||||
|
}
|
||||||
|
} on SocketException catch (e) {
|
||||||
|
if (tmp.existsSync()) tmp.deleteSync();
|
||||||
|
if (attempt == maxAttempts) {
|
||||||
|
_fail('Socket error downloading asset: $url ($e)');
|
||||||
|
}
|
||||||
|
} on HttpException catch (e) {
|
||||||
|
if (tmp.existsSync()) tmp.deleteSync();
|
||||||
|
if (attempt == maxAttempts) {
|
||||||
|
_fail('HTTP error downloading asset: $url ($e)');
|
||||||
|
}
|
||||||
|
} on FileSystemException catch (e) {
|
||||||
|
if (tmp.existsSync()) tmp.deleteSync();
|
||||||
|
_fail('File write error downloading asset: $url ($e)');
|
||||||
|
} catch (e) {
|
||||||
|
if (tmp.existsSync()) tmp.deleteSync();
|
||||||
|
if (attempt == maxAttempts) {
|
||||||
|
_fail('Unexpected error downloading asset: $url ($e)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backoff before retrying.
|
||||||
|
final backoffSeconds = 1 << (attempt - 1);
|
||||||
|
stdout.writeln(
|
||||||
|
'Retrying (${attempt + 1}/$maxAttempts) for: $url (wait ${backoffSeconds}s)',
|
||||||
|
);
|
||||||
|
await Future.delayed(Duration(seconds: backoffSeconds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory _findFlutterProjectRoot(Directory start) {
|
||||||
|
var dir = start;
|
||||||
|
for (var i = 0; i < 10; i++) {
|
||||||
|
final pubspec = File(_join(dir.path, 'pubspec.yaml'));
|
||||||
|
if (pubspec.existsSync()) return dir;
|
||||||
|
final parent = dir.parent;
|
||||||
|
if (parent.path == dir.path) break;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
// Fallback: use current directory, error messages will explain what is missing.
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
Never _fail(String msg) {
|
||||||
|
throw FormatException(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _join(String a, [String? b, String? c, String? d]) {
|
||||||
|
final parts = <String>[
|
||||||
|
a,
|
||||||
|
if (b != null) b,
|
||||||
|
if (c != null) c,
|
||||||
|
if (d != null) d,
|
||||||
|
];
|
||||||
|
return parts.join(Platform.pathSeparator);
|
||||||
|
}
|
||||||
@@ -25,10 +25,11 @@ on:
|
|||||||
- ".github/workflows/test.yml"
|
- ".github/workflows/test.yml"
|
||||||
- "go.mod"
|
- "go.mod"
|
||||||
- "go.sum"
|
- "go.sum"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: "1.24"
|
GO_VERSION: "1.24"
|
||||||
FLUTTER_VERSION: "3.24.5"
|
FLUTTER_VERSION: "3.41.2"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# lint:
|
# lint:
|
||||||
@@ -65,21 +66,56 @@ jobs:
|
|||||||
files: ./coverage.txt
|
files: ./coverage.txt
|
||||||
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
|
||||||
build-desktop:
|
build-desktop:
|
||||||
name: Build desktop check
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [windows-latest, macos-latest, ubuntu-22.04, ubuntu-22.04-arm]
|
include:
|
||||||
|
- os: windows-2022
|
||||||
|
- os: windows-11-arm
|
||||||
|
llvm_ver: "20251202" # Only ARM64
|
||||||
|
flutter_channel: "main"
|
||||||
|
flutter_version: "7e1c8868"
|
||||||
|
- os: macos-latest
|
||||||
|
- os: ubuntu-22.04
|
||||||
|
- os: ubuntu-22.04-arm
|
||||||
|
flutter_channel: "main"
|
||||||
|
name: Build desktop check (${{ matrix.os }})
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
needs: [test]
|
needs: [test]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
- name: Enable long paths for flutter main branch checks
|
||||||
|
run: |
|
||||||
|
git config --global core.longpaths true
|
||||||
|
- name: Install llvm-mingw-ucrt-aarch64 (ARM64 only)
|
||||||
|
if: matrix.os == 'windows-11-arm'
|
||||||
|
run: |
|
||||||
|
$ver = "${{ matrix.llvm_ver }}"
|
||||||
|
$url = "https://github.com/mstorsjo/llvm-mingw/releases/download/$ver/llvm-mingw-$ver-ucrt-aarch64.zip"
|
||||||
|
$zip = "$env:RUNNER_TEMP\\llvm.zip"
|
||||||
|
$extract = "$env:RUNNER_TEMP\\extract"
|
||||||
|
$target = "C:\\clangarm64"
|
||||||
|
|
||||||
|
curl -L $url -o $zip
|
||||||
|
rm -r -fo $extract,$target -ea Ignore
|
||||||
|
mkdir $extract | Out-Null
|
||||||
|
|
||||||
|
tar -xf $zip -C $extract
|
||||||
|
mv (Get-ChildItem $extract)[0].FullName $target
|
||||||
|
|
||||||
|
$b = "$target\\bin"
|
||||||
|
"CC=$b\\clang.exe" >> $env:GITHUB_ENV
|
||||||
|
"CXX=$b\\clang++.exe" >> $env:GITHUB_ENV
|
||||||
|
"CLANGARM64_BIN=$b" >> $env:GITHUB_ENV
|
||||||
|
"CGO_ENABLED=1" >> $env:GITHUB_ENV
|
||||||
|
"CLANGARM64_ROOT=$target" >> $env:GITHUB_ENV
|
||||||
|
$b >> $env:GITHUB_PATH
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- uses: subosito/flutter-action@v2
|
- uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
channel: ${{ matrix.flutter_channel || 'stable' }}
|
||||||
channel: master
|
flutter-version: ${{ matrix.flutter_version || env.FLUTTER_VERSION }}
|
||||||
- if: runner.os == 'Windows'
|
- if: runner.os == 'Windows'
|
||||||
run: |
|
run: |
|
||||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/windows/libgopeed.dll github.com/GopeedLab/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
|
||||||
@@ -93,7 +129,7 @@ jobs:
|
|||||||
- if: runner.os == 'Linux'
|
- if: runner.os == 'Linux'
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update -y
|
sudo apt-get update -y
|
||||||
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev
|
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev libkeybinder-3.0-dev
|
||||||
go build -tags nosqlite -ldflags="-w -s" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/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
|
cd ui/flutter
|
||||||
flutter build linux
|
flutter build linux
|
||||||
@@ -148,6 +184,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cd ui/flutter
|
cd ui/flutter
|
||||||
flutter build web --no-web-resources-cdn
|
flutter build web --no-web-resources-cdn
|
||||||
|
dart ../../.github/workflows/scripts/flutter_local_font.dart
|
||||||
cd ../../
|
cd ../../
|
||||||
rm -rf cmd/web/dist
|
rm -rf cmd/web/dist
|
||||||
cp -r ui/flutter/build/web cmd/web/dist
|
cp -r ui/flutter/build/web cmd/web/dist
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.23.3 AS go
|
FROM golang:1.24.11-alpine3.23 AS go
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY ./go.mod ./go.sum ./
|
COPY ./go.mod ./go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
@@ -8,7 +8,7 @@ RUN CGO_ENABLED=0 go build -tags nosqlite,web \
|
|||||||
-ldflags="-s -w -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION -X github.com/GopeedLab/gopeed/pkg/base.InDocker=true" \
|
-ldflags="-s -w -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION -X github.com/GopeedLab/gopeed/pkg/base.InDocker=true" \
|
||||||
-o dist/gopeed github.com/GopeedLab/gopeed/cmd/web
|
-o dist/gopeed github.com/GopeedLab/gopeed/cmd/web
|
||||||
|
|
||||||
FROM alpine:3.18
|
FROM alpine:3.23
|
||||||
LABEL maintainer="monkeyWie"
|
LABEL maintainer="monkeyWie"
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=go /app/dist/gopeed ./
|
COPY --from=go /app/dist/gopeed ./
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# [](https://gopeed.com)
|
# [](https://gopeed.com)
|
||||||
|
|
||||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://docs.gopeed.com/donate.html)
|
[](https://gopeed.com/docs/donate)
|
||||||
|
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||||
[](https://discord.gg/ZUJqJrwCGB)
|
[](https://discord.gg/ZUJqJrwCGB)
|
||||||
|
|
||||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
@@ -15,25 +16,33 @@
|
|||||||
|
|
||||||
## 🚀 Introduction
|
## 🚀 Introduction
|
||||||
|
|
||||||
Gopeed (full name Go Speed), a high-speed downloader developed by `Golang` + `Flutter`, supports (HTTP, BitTorrent, Magnet) protocol, and supports all platforms. In addition to basic download functions, Gopeed is also a highly customizable downloader that supports implementing more features through integration with [APIs](https://docs.gopeed.com/dev-api.html) or installation and development of [extensions](https://docs.gopeed.com/dev-extension.html).
|
Gopeed (full name Go Speed), a high-speed downloader developed by `Golang` + `Flutter`, supports (HTTP, BitTorrent, Magnet, ED2K) protocol, and supports all platforms. In addition to basic download functions, Gopeed is also a highly customizable downloader that supports implementing more features through integration with [APIs](https://gopeed.com/docs/dev-api) or installation and development of [extensions](https://gopeed.com/docs/dev-extension).
|
||||||
|
|
||||||
Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://docs.gopeed.com)
|
Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://gopeed.com/docs)
|
||||||
|
|
||||||
## ⬇️ Download
|
## ⬇️ Download
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="2">🪟 Windows</td>
|
<td rowspan="4">🪟 Windows</td>
|
||||||
<td><code>EXE</code></td>
|
<td rowspan="2"><code>EXE</code></td>
|
||||||
<td>amd64</td>
|
<td>amd64</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64.zip">📥</a></td>
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64.zip">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>Portable</code></td>
|
<td>arm64</td>
|
||||||
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-arm64.zip">📥</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td rowspan="2"><code>Portable</code></td>
|
||||||
<td>amd64</td>
|
<td>amd64</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64-portable.zip">📥</a></td>
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64-portable.zip">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>arm64</td>
|
||||||
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-arm64-portable.zip">📥</a></td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="3">🍎 MacOS</td>
|
<td rowspan="3">🍎 MacOS</td>
|
||||||
<td rowspan="3"><code>DMG</code></td>
|
<td rowspan="3"><code>DMG</code></td>
|
||||||
@@ -156,7 +165,7 @@ Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
More about installation, please refer to [Installation](https://docs.gopeed.com/install.html)
|
More about installation, please refer to [Installation](https://gopeed.com/docs/install)
|
||||||
|
|
||||||
### 🛠️ Command tool
|
### 🛠️ Command tool
|
||||||
|
|
||||||
@@ -170,9 +179,15 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
|||||||
|
|
||||||
Gopeed also provides a browser extension to take over browser downloads, supporting browsers such as Chrome, Edge, Firefox, etc., please refer to: [https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
|
Gopeed also provides a browser extension to take over browser downloads, supporting browsers such as Chrome, Edge, Firefox, etc., please refer to: [https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
|
||||||
|
|
||||||
|
## 📱 WeChat Official Account
|
||||||
|
|
||||||
|
Follow our WeChat Official Account to get the latest updates and news.
|
||||||
|
|
||||||
|
<img src="_docs/img/weixin.png" width="200" />
|
||||||
|
|
||||||
## 💝 Donate
|
## 💝 Donate
|
||||||
|
|
||||||
If you like this project, please consider [donating](https://docs.gopeed.com/donate.html) to support the development of this project, thank you!
|
If you like this project, please consider [donating](https://gopeed.com/docs/donate) to support the development of this project, thank you!
|
||||||
|
|
||||||
## 🖼️ Showcase
|
## 🖼️ Showcase
|
||||||
|
|
||||||
@@ -186,8 +201,8 @@ This project is divided into two parts, the front end uses `flutter`, the back e
|
|||||||
|
|
||||||
### 🌍 Environment
|
### 🌍 Environment
|
||||||
|
|
||||||
1. Golang 1.23+
|
1. Golang 1.24+
|
||||||
2. Flutter 3.24+
|
2. Flutter 3.38+
|
||||||
|
|
||||||
### 📋 Clone
|
### 📋 Clone
|
||||||
|
|
||||||
|
|||||||
+15
-8
@@ -1,10 +1,11 @@
|
|||||||
# [](https://gopeed.com)
|
# [](https://gopeed.com)
|
||||||
|
|
||||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://docs.gopeed.com/donate.html)
|
[](https://gopeed.com/docs/donate)
|
||||||
|
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||||
[](https://discord.gg/ZUJqJrwCGB)
|
[](https://discord.gg/ZUJqJrwCGB)
|
||||||
|
|
||||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
@@ -15,9 +16,9 @@
|
|||||||
|
|
||||||
## 🚀 はじめに
|
## 🚀 はじめに
|
||||||
|
|
||||||
Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された高速ダウンローダーで、(HTTP、BitTorrent、Magnet) プロトコルをサポートし、すべてのプラットフォームをサポートします。基本的なダウンロード機能に加え、[APIs](https://docs.gopeed.com/dev-api.html)との連動や[拡張機能](https://docs.gopeed.com/dev-extension.html)のインストール・開発による追加機能にも対応した、カスタマイズ性の高いダウンローダーです。
|
Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された高速ダウンローダーで、(HTTP、BitTorrent、Magnet、ED2K) プロトコルをサポートし、すべてのプラットフォームをサポートします。基本的なダウンロード機能に加え、[APIs](https://gopeed.com/docs/dev-api)との連動や[拡張機能](https://gopeed.com/docs/dev-extension)のインストール・開発による追加機能にも対応した、カスタマイズ性の高いダウンローダーです。
|
||||||
|
|
||||||
見て下さい ✈ [公式ウェブサイト](https://gopeed.com) | 📖 [開発ドキュメント](https://docs.gopeed.com)
|
見て下さい ✈ [公式ウェブサイト](https://gopeed.com) | 📖 [開発ドキュメント](https://gopeed.com/docs)
|
||||||
|
|
||||||
## ⬇️ インストール
|
## ⬇️ インストール
|
||||||
|
|
||||||
@@ -155,13 +156,19 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
インストールについての詳細は、[インストール](https://docs.gopeed.com/install.html)を参照してください。
|
インストールについての詳細は、[インストール](https://gopeed.com/docs/install)を参照してください。
|
||||||
|
|
||||||
### 🛠️ コマンドツール
|
### 🛠️ コマンドツール
|
||||||
|
|
||||||
|
## 📱 WeChat 公式アカウント
|
||||||
|
|
||||||
|
公式アカウントをフォローして、最新のアップデートやニュースを入手してください。
|
||||||
|
|
||||||
|
<img src="_docs/img/weixin.png" width="200" />
|
||||||
|
|
||||||
## 💝 寄付
|
## 💝 寄付
|
||||||
|
|
||||||
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://docs.gopeed.com/donate.html)をご検討ください!
|
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://gopeed.com/docs/donate)をご検討ください!
|
||||||
|
|
||||||
## 🖼️ ショーケース
|
## 🖼️ ショーケース
|
||||||
|
|
||||||
@@ -175,8 +182,8 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
|
|||||||
|
|
||||||
### 🌍 環境
|
### 🌍 環境
|
||||||
|
|
||||||
1. Go 言語 1.23+
|
1. Go 言語 1.24+
|
||||||
2. Flutter 3.24+
|
2. Flutter 3.38+
|
||||||
|
|
||||||
### 📋 クローン
|
### 📋 クローン
|
||||||
|
|
||||||
|
|||||||
+15
-8
@@ -1,10 +1,11 @@
|
|||||||
# [](https://gopeed.com)
|
# [](https://gopeed.com)
|
||||||
|
|
||||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://docs.gopeed.com/donate.html)
|
[](https://gopeed.com/docs/donate)
|
||||||
|
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||||
[](https://discord.gg/ZUJqJrwCGB)
|
[](https://discord.gg/ZUJqJrwCGB)
|
||||||
|
|
||||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
@@ -15,9 +16,9 @@
|
|||||||
|
|
||||||
## 🚀 Giới thiệu
|
## 🚀 Giới thiệu
|
||||||
|
|
||||||
Gopeed (tên đầy đủ Go Speed), một công cụ tải xuống tốc độ cao được phát triển bởi `Golang` + `Flutter`, hỗ trợ giao thức (HTTP, BitTorrent, Magnet) và hỗ trợ tất cả các nền tảng. Ngoài các chức năng tải xuống cơ bản, Gopeed còn là một công cụ tải xuống có thể tùy chỉnh cao cho phép triển khai thêm tính năng thông qua việc tích hợp với [APIs](https://docs.gopeed.com/dev-api.html) hoặc cài đặt và phát triển các [tiện ích mở rộng](https://docs.gopeed.com/dev-extension.html).
|
Gopeed (tên đầy đủ Go Speed), một công cụ tải xuống tốc độ cao được phát triển bởi `Golang` + `Flutter`, hỗ trợ giao thức (HTTP, BitTorrent, Magnet, ED2K) và hỗ trợ tất cả các nền tảng. Ngoài các chức năng tải xuống cơ bản, Gopeed còn là một công cụ tải xuống có thể tùy chỉnh cao cho phép triển khai thêm tính năng thông qua việc tích hợp với [APIs](https://gopeed.com/docs/dev-api) hoặc cài đặt và phát triển các [tiện ích mở rộng](https://gopeed.com/docs/dev-extension).
|
||||||
|
|
||||||
Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu chính thức](https://docs.gopeed.com)
|
Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu chính thức](https://gopeed.com/docs)
|
||||||
|
|
||||||
## ⬇️ Tải về
|
## ⬇️ Tải về
|
||||||
|
|
||||||
@@ -155,7 +156,7 @@ Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
Thêm thông tin về cài đặt, vui lòng tham khảo [Cài đặt](https://docs.gopeed.com/install.html)
|
Thêm thông tin về cài đặt, vui lòng tham khảo [Cài đặt](https://gopeed.com/docs/install)
|
||||||
|
|
||||||
### 🛠️ Công cụ lệnh
|
### 🛠️ Công cụ lệnh
|
||||||
|
|
||||||
@@ -165,9 +166,15 @@ Sử dụng `go install`:
|
|||||||
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 📱 WeChat Official Account
|
||||||
|
|
||||||
|
Theo dõi tài khoản chính thức để nhận các cập nhật và tin tức mới nhất.
|
||||||
|
|
||||||
|
<img src="_docs/img/weixin.png" width="200" />
|
||||||
|
|
||||||
## 💝 Quyên góp
|
## 💝 Quyên góp
|
||||||
|
|
||||||
Nếu bạn thích dự án này, xin vui lòng xem xét [quyên góp](https://docs.gopeed.com/donate.html) để hỗ trợ phát triển dự án này, cảm ơn bạn!
|
Nếu bạn thích dự án này, xin vui lòng xem xét [quyên góp](https://gopeed.com/docs/donate) để hỗ trợ phát triển dự án này, cảm ơn bạn!
|
||||||
|
|
||||||
## 🖼️ Trưng bày
|
## 🖼️ Trưng bày
|
||||||
|
|
||||||
@@ -181,8 +188,8 @@ Dự án này được chia thành hai phần, phần giao diện sử dụng `f
|
|||||||
|
|
||||||
### 🌍 Environment
|
### 🌍 Environment
|
||||||
|
|
||||||
1. Golang 1.23+
|
1. Golang 1.24+
|
||||||
2. Flutter 3.24+
|
2. Flutter 3.38+
|
||||||
|
|
||||||
### 📋 Clone
|
### 📋 Clone
|
||||||
|
|
||||||
|
|||||||
+15
-8
@@ -1,10 +1,11 @@
|
|||||||
# [](https://gopeed.com)
|
# [](https://gopeed.com)
|
||||||
|
|
||||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://docs.gopeed.com/zh/donate.html)
|
[](https://gopeed.com/docs/donate)
|
||||||
|
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||||
[](https://discord.gg/ZUJqJrwCGB)
|
[](https://discord.gg/ZUJqJrwCGB)
|
||||||
|
|
||||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
@@ -15,9 +16,9 @@
|
|||||||
|
|
||||||
## 🚀 介绍
|
## 🚀 介绍
|
||||||
|
|
||||||
Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不是狗屁下载器!),是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet)协议下载,并且支持全平台使用。除了基本的下载功能外,Gopeed 还是一款高度可定制化的下载器,支持通过对接[APIs](https://docs.gopeed.com/zh/dev-api.html)或者安装和开发[扩展](https://docs.gopeed.com/zh/dev-extension.html)来实现更多的功能。
|
Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不是狗屁下载器!),是一款由`Golang`+`Flutter`开发的高速下载器,支持(HTTP、BitTorrent、Magnet、ED2K)协议下载,并且支持全平台使用。除了基本的下载功能外,Gopeed 还是一款高度可定制化的下载器,支持通过对接[APIs](https://gopeed.com/docs/dev-api)或者安装和开发[扩展](https://gopeed.com/docs/dev-extension)来实现更多的功能。
|
||||||
|
|
||||||
访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [官方文档](https://docs.gopeed.com/zh/)
|
访问 ✈ [官方网站](https://gopeed.com/zh-CN) | 📖 [官方文档](https://gopeed.com/docs)
|
||||||
|
|
||||||
## ⬇️ 下载
|
## ⬇️ 下载
|
||||||
|
|
||||||
@@ -155,7 +156,7 @@ Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
更多关于安装的内容请参考[安装文档](https://docs.gopeed.com/zh/install.html)
|
更多关于安装的内容请参考[安装文档](https://gopeed.com/docs/install)
|
||||||
|
|
||||||
### 🛠️ 命令行工具
|
### 🛠️ 命令行工具
|
||||||
|
|
||||||
@@ -169,9 +170,15 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
|||||||
|
|
||||||
Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、Edge、Firefox 等浏览器,具体请参考:[https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
|
Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、Edge、Firefox 等浏览器,具体请参考:[https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
|
||||||
|
|
||||||
|
## 📱 微信公众号
|
||||||
|
|
||||||
|
关注公众号获取项目最新动态和资讯。
|
||||||
|
|
||||||
|
<img src="_docs/img/weixin.png" width="200" />
|
||||||
|
|
||||||
## 💝 赞助
|
## 💝 赞助
|
||||||
|
|
||||||
如果觉得项目对你有帮助,请考虑[赞助](https://docs.gopeed.com/zh/donate)以支持这个项目的发展,非常感谢!
|
如果觉得项目对你有帮助,请考虑[赞助](https://gopeed.com/docs/donate)以支持这个项目的发展,非常感谢!
|
||||||
|
|
||||||
## 🖼️ 界面展示
|
## 🖼️ 界面展示
|
||||||
|
|
||||||
@@ -185,8 +192,8 @@ Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、
|
|||||||
|
|
||||||
### 🌍 环境要求
|
### 🌍 环境要求
|
||||||
|
|
||||||
1. Golang 1.23+
|
1. Golang 1.24+
|
||||||
2. Flutter 3.24+
|
2. Flutter 3.38+
|
||||||
|
|
||||||
### 📋 克隆项目
|
### 📋 克隆项目
|
||||||
|
|
||||||
|
|||||||
+15
-8
@@ -1,10 +1,11 @@
|
|||||||
# [](https://gopeed.com)
|
# [](https://gopeed.com)
|
||||||
|
|
||||||
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
[](https://github.com/GopeedLab/gopeed/actions?query=workflow%3Atest)
|
||||||
[](https://codecov.io/gh/GopeedLab/gopeed)
|
[](https://codecov.io/gh/GopeedLab/gopeed)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://github.com/GopeedLab/gopeed/releases)
|
[](https://github.com/GopeedLab/gopeed/releases)
|
||||||
[](https://docs.gopeed.com/donate.html)
|
[](https://gopeed.com/docs/donate)
|
||||||
|
[](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
|
||||||
[](https://discord.gg/ZUJqJrwCGB)
|
[](https://discord.gg/ZUJqJrwCGB)
|
||||||
|
|
||||||
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
@@ -15,9 +16,9 @@
|
|||||||
|
|
||||||
## 🚀 簡介
|
## 🚀 簡介
|
||||||
|
|
||||||
Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速下載軟體,支援(HTTP、BitTorrent、Magnet)協定,同時支援所有的平台。
|
Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速下載軟體,支援(HTTP、BitTorrent、Magnet、ED2K)協定,同時支援所有的平台。
|
||||||
|
|
||||||
前往 ✈ [主頁](https://gopeed.com/zh-CN) | 📖 [文檔](https://docs.gopeed.com/zh/)
|
前往 ✈ [主頁](https://gopeed.com/zh-CN) | 📖 [文檔](https://gopeed.com/docs)
|
||||||
|
|
||||||
## ⬇️ 下載
|
## ⬇️ 下載
|
||||||
|
|
||||||
@@ -156,7 +157,7 @@ Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
更多關於安裝的內容請參考[安裝文檔](https://docs.gopeed.com/zh/install.html)
|
更多關於安裝的內容請參考[安裝文檔](https://gopeed.com/docs/install)
|
||||||
|
|
||||||
### 🛠️ 使用 CLI 安裝
|
### 🛠️ 使用 CLI 安裝
|
||||||
|
|
||||||
@@ -166,9 +167,15 @@ Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速
|
|||||||
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 📱 微信公眾號
|
||||||
|
|
||||||
|
關注公眾號獲取項目最新動態和資訊。
|
||||||
|
|
||||||
|
<img src="_docs/img/weixin.png" width="200" />
|
||||||
|
|
||||||
## 💝 贊助
|
## 💝 贊助
|
||||||
|
|
||||||
如果你認為該項目對你有所幫助,請考慮[贊助](https://docs.gopeed.com/zh/donate)以支持該項目的持續發展,謝謝!
|
如果你認為該項目對你有所幫助,請考慮[贊助](https://gopeed.com/docs/donate)以支持該項目的持續發展,謝謝!
|
||||||
|
|
||||||
## 🖼️ 軟體介面
|
## 🖼️ 軟體介面
|
||||||
|
|
||||||
@@ -182,8 +189,8 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
|||||||
|
|
||||||
### 🌍 開發環境
|
### 🌍 開發環境
|
||||||
|
|
||||||
1. Golang 1.23+
|
1. Golang 1.24+
|
||||||
2. Flutter 3.24+
|
2. Flutter 3.38+
|
||||||
|
|
||||||
### 📋 克隆項目
|
### 📋 克隆項目
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,54 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 400" width="1200" height="400">
|
||||||
|
<rect width="1200" height="400" fill="#040605"/>
|
||||||
|
<g fill="#5dc76c">
|
||||||
|
<rect x="-100" y="50" width="150" height="2" opacity="0.6">
|
||||||
|
<animate attributeName="x" from="-200" to="1300" dur="0.8s" repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
<rect x="-200" y="150" width="200" height="1" opacity="0.4">
|
||||||
|
<animate attributeName="x" from="-300" to="1300" dur="1.2s" repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
<rect x="-50" y="250" width="180" height="3" opacity="0.8">
|
||||||
|
<animate attributeName="x" from="-200" to="1300" dur="0.6s" repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
<rect x="-150" y="350" width="220" height="2" opacity="0.5">
|
||||||
|
<animate attributeName="x" from="-300" to="1300" dur="1s" repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
<rect x="-100" y="100" width="100" height="1" opacity="0.7">
|
||||||
|
<animate attributeName="x" from="-150" to="1300" dur="0.9s" repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
<rect x="-250" y="300" width="250" height="2" opacity="0.3">
|
||||||
|
<animate attributeName="x" from="-400" to="1300" dur="1.5s" repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="logoGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="12" result="blur" />
|
||||||
|
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<g transform="translate(600, 160)">
|
||||||
|
<!-- Floating animation -->
|
||||||
|
<g>
|
||||||
|
<animateTransform attributeName="transform" type="translate" values="0,-8; 0,8; 0,-8" dur="3s" repeatCount="indefinite" />
|
||||||
|
|
||||||
|
<!-- Pulsing glow behind logo -->
|
||||||
|
<circle cx="0" cy="0" r="45" fill="#5dc76c" filter="url(#logoGlow)" opacity="0.3">
|
||||||
|
<animate attributeName="opacity" values="0.1; 0.6; 0.1" dur="2s" repeatCount="indefinite" />
|
||||||
|
<animate attributeName="r" values="40; 65; 40" dur="2s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
|
||||||
|
<!-- Breathing scale animation -->
|
||||||
|
<g>
|
||||||
|
<animateTransform attributeName="transform" type="scale" values="0.95; 1.05; 0.95" dur="2s" repeatCount="indefinite" />
|
||||||
|
<g transform="scale(0.12) translate(-512, -512)">
|
||||||
|
<path style="opacity:0.998" fill="#5dc76c" d="M 951.5,763.5 C 940.739,755.276 929.405,747.776 917.5,741C 908.477,736.319 899.143,732.485 889.5,729.5C 885.844,625.779 840.844,545.613 754.5,489C 674.802,441.453 590.135,430.12 500.5,455C 419.755,480.605 359.588,530.771 320,605.5C 278.585,565.816 230.752,555.649 176.5,575C 131.948,594.935 106.448,629.102 100,677.5C 99.3333,685.833 99.3333,694.167 100,702.5C 102.081,714.824 105.081,726.824 109,738.5C 95.4882,745.492 82.9882,753.826 71.5,763.5C 37.7121,705.639 16.5454,643.306 8,576.5C -9.41698,416.759 35.9164,278.093 144,160.5C 244.661,58.2827 367.161,5.94939 511.5,3.5C 678.975,7.48829 814.142,74.8216 917,205.5C 1011.33,336.426 1040,480.759 1003,638.5C 991.564,682.671 974.397,724.337 951.5,763.5 Z"/>
|
||||||
|
<path style="opacity:0.999" fill="#fefefe" d="M 951.5,763.5 C 949.28,768.272 946.78,772.938 944,777.5C 938.305,786.913 932.138,795.913 925.5,804.5C 915.954,796.888 905.288,791.055 893.5,787C 878.282,782.958 863.115,778.791 848,774.5C 854.013,716.891 841.346,663.891 810,615.5C 768.341,553.228 711.174,512.395 638.5,493C 560.311,479.566 490.311,497.566 428.5,547C 411.963,560.536 397.13,575.703 384,592.5C 366.745,616.923 348.911,640.923 330.5,664.5C 310.135,651.302 290.469,637.135 271.5,622C 238.448,602.307 206.782,604.307 176.5,628C 159.375,642.752 148.875,661.252 145,683.5C 143.968,692.716 144.968,701.716 148,710.5C 154.334,725.339 160.167,740.339 165.5,755.5C 138.118,764.688 114.451,779.688 94.5,800.5C 85.653,788.968 77.9863,776.635 71.5,763.5C 82.9882,753.826 95.4882,745.492 109,738.5C 105.081,726.824 102.081,714.824 100,702.5C 99.3333,694.167 99.3333,685.833 100,677.5C 106.448,629.102 131.948,594.935 176.5,575C 230.752,555.649 278.585,565.816 320,605.5C 359.588,530.771 419.755,480.605 500.5,455C 590.135,430.12 674.802,441.453 754.5,489C 840.844,545.613 885.844,625.779 889.5,729.5C 899.143,732.485 908.477,736.319 917.5,741C 929.405,747.776 940.739,755.276 951.5,763.5 Z"/>
|
||||||
|
<path style="opacity:0.998" fill="#5dc76c" d="M 925.5,804.5 C 850.791,908.608 750.791,975.775 625.5,1006C 609.308,1009.7 592.975,1012.53 576.5,1014.5C 615.708,969.938 655.042,925.438 694.5,881C 664.168,880.5 633.835,880.333 603.5,880.5C 603.5,811.167 603.5,741.833 603.5,672.5C 542.167,672.5 480.833,672.5 419.5,672.5C 419.5,741.5 419.5,810.5 419.5,879.5C 388.493,879.167 357.493,879.5 326.5,880.5C 326.624,881.107 326.957,881.44 327.5,881.5C 366.805,925.808 406.138,970.141 445.5,1014.5C 318.63,996.317 212.796,939.651 128,844.5C 115.85,830.525 104.683,815.859 94.5,800.5C 114.451,779.688 138.118,764.688 165.5,755.5C 160.167,740.339 154.334,725.339 148,710.5C 144.968,701.716 143.968,692.716 145,683.5C 148.875,661.252 159.375,642.752 176.5,628C 206.782,604.307 238.448,602.307 271.5,622C 290.469,637.135 310.135,651.302 330.5,664.5C 348.911,640.923 366.745,616.923 384,592.5C 397.13,575.703 411.963,560.536 428.5,547C 490.311,497.566 560.311,479.566 638.5,493C 711.174,512.395 768.341,553.228 810,615.5C 841.346,663.891 854.013,716.891 848,774.5C 863.115,778.791 878.282,782.958 893.5,787C 905.288,791.055 915.954,796.888 925.5,804.5 Z"/>
|
||||||
|
<path style="opacity:0.999" fill="#fefefe" d="M 576.5,1014.5 C 532.836,1019.82 489.169,1019.82 445.5,1014.5C 406.138,970.141 366.805,925.808 327.5,881.5C 358.167,880.833 388.833,880.167 419.5,879.5C 419.5,810.5 419.5,741.5 419.5,672.5C 480.833,672.5 542.167,672.5 603.5,672.5C 603.5,741.833 603.5,811.167 603.5,880.5C 633.835,880.333 664.168,880.5 694.5,881C 655.042,925.438 615.708,969.938 576.5,1014.5 Z"/>
|
||||||
|
<path style="opacity:1" fill="#ade2b5" d="M 419.5,879.5 C 388.833,880.167 358.167,880.833 327.5,881.5C 326.957,881.44 326.624,881.107 326.5,880.5C 357.493,879.5 388.493,879.167 419.5,879.5 Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<text x="600" y="300" font-family="sans-serif" font-size="72" font-style="italic" font-weight="900" text-anchor="middle" fill="#fff" letter-spacing="8">GOPEED</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
+3
-2
@@ -2,13 +2,14 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
"github.com/GopeedLab/gopeed/pkg/download"
|
"github.com/GopeedLab/gopeed/pkg/download"
|
||||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||||
"github.com/GopeedLab/gopeed/pkg/util"
|
"github.com/GopeedLab/gopeed/pkg/util"
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const progressWidth = 20
|
const progressWidth = 20
|
||||||
|
|||||||
+54
-14
@@ -60,10 +60,39 @@ func wakeup(hidden bool) error {
|
|||||||
return fmt.Errorf("start gopeed failed")
|
return fmt.Errorf("start gopeed failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// postToFlutter sends a POST request to Flutter RPC server
|
||||||
|
func postToFlutter(path string, body []byte, headers map[string]string, timeout time.Duration) (*http.Response, error) {
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
return Dial()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Timeout: timeout,
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest("POST", "http://127.0.0.1"+path, bytes.NewBuffer(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
return client.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
var apiMap = map[string]func(message *Message) (data any, err error){
|
var apiMap = map[string]func(message *Message) (data any, err error){
|
||||||
"ping": func(message *Message) (data any, err error) {
|
"ping": func(message *Message) (data any, err error) {
|
||||||
return check()
|
return check()
|
||||||
},
|
},
|
||||||
|
"wakeup": func(message *Message) (data any, err error) {
|
||||||
|
silent := false
|
||||||
|
if v, ok := message.Meta["silent"]; ok {
|
||||||
|
silent, _ = v.(bool)
|
||||||
|
}
|
||||||
|
err = wakeup(silent)
|
||||||
|
return
|
||||||
|
},
|
||||||
"create": func(message *Message) (data any, err error) {
|
"create": func(message *Message) (data any, err error) {
|
||||||
buf, err := message.Params.MarshalJSON()
|
buf, err := message.Params.MarshalJSON()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,24 +108,36 @@ var apiMap = map[string]func(message *Message) (data any, err error){
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{
|
headers := make(map[string]string)
|
||||||
Transport: &http.Transport{
|
if message.Meta != nil {
|
||||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
metaJson, _ := json.Marshal(message.Meta)
|
||||||
return Dial()
|
headers["X-Gopeed-Host-Meta"] = string(metaJson)
|
||||||
},
|
|
||||||
},
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
}
|
}
|
||||||
req, err := http.NewRequest("POST", "http://127.0.0.1/create", bytes.NewBuffer(buf))
|
_, err = postToFlutter("/create", buf, headers, 10*time.Second)
|
||||||
|
return
|
||||||
|
},
|
||||||
|
"forward": func(message *Message) (data any, err error) {
|
||||||
|
buf, err := message.Params.MarshalJSON()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if message.Meta != nil {
|
|
||||||
metaJson, _ := json.Marshal(message.Meta)
|
resp, err := postToFlutter("/forward", buf, nil, 60*time.Second)
|
||||||
req.Header.Set("X-Gopeed-Host-Meta", string(metaJson))
|
if err != nil {
|
||||||
}
|
|
||||||
_, err = client.Do(req)
|
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var respData map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(respBody, &respData); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return respData, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +149,6 @@ func main() {
|
|||||||
var length uint32
|
var length uint32
|
||||||
if err := binary.Read(os.Stdin, binary.NativeEndian, &length); err != nil {
|
if err := binary.Read(os.Stdin, binary.NativeEndian, &length); err != nil {
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
// Connection closed by Chrome
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendError("Failed to read message length: " + err.Error())
|
sendError("Failed to read message length: " + err.Error())
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
module github.com/GopeedLab/gopeed
|
module github.com/GopeedLab/gopeed
|
||||||
|
|
||||||
go 1.23.0
|
go 1.24.9
|
||||||
|
|
||||||
toolchain go1.23.1
|
toolchain go1.24.11
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/anacrolix/missinggo/v2 v2.8.0
|
github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0
|
||||||
github.com/anacrolix/torrent v1.57.2-0.20241017235801-4d8437a05621
|
|
||||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
|
||||||
github.com/dop251/goja v0.0.0-20240919115326-6c7d1df7ff05
|
github.com/bodgit/sevenzip v1.6.1
|
||||||
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||||
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
|
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
|
||||||
github.com/go-git/go-git/v5 v5.8.1
|
github.com/go-git/go-git/v5 v5.8.1
|
||||||
github.com/gorilla/handlers v1.5.1
|
github.com/gorilla/handlers v1.5.1
|
||||||
@@ -16,137 +16,150 @@ require (
|
|||||||
github.com/imroc/req/v3 v3.52.2
|
github.com/imroc/req/v3 v3.52.2
|
||||||
github.com/matoous/go-nanoid/v2 v2.0.0
|
github.com/matoous/go-nanoid/v2 v2.0.0
|
||||||
github.com/mattn/go-ieproxy v0.0.12
|
github.com/mattn/go-ieproxy v0.0.12
|
||||||
|
github.com/mholt/archives v0.1.5
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529
|
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529
|
||||||
github.com/rs/zerolog v1.31.0
|
github.com/rs/zerolog v1.31.0
|
||||||
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad
|
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad
|
||||||
go.etcd.io/bbolt v1.3.11
|
go.etcd.io/bbolt v1.4.3
|
||||||
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
|
||||||
golang.org/x/sync v0.13.0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||||
github.com/ebitengine/purego v0.8.1 // indirect
|
github.com/anacrolix/btree v0.1.1 // indirect
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
github.com/anacrolix/missinggo/v2 v2.10.0 // indirect
|
||||||
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
|
github.com/bodgit/plumbing v1.3.0 // indirect
|
||||||
|
github.com/bodgit/windows v1.0.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||||
|
github.com/felixge/fgprof v0.9.5 // indirect
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/icholy/digest v1.1.0 // indirect
|
github.com/icholy/digest v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c // indirect
|
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||||
|
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||||
|
github.com/minio/minlz v1.0.1 // indirect
|
||||||
|
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447 // indirect
|
||||||
|
github.com/nwaples/rardecode/v2 v2.2.0 // indirect
|
||||||
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
|
github.com/onsi/ginkgo/v2 v2.23.4 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c // indirect
|
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||||
|
github.com/pion/transport/v4 v4.0.1 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.51.0 // indirect
|
github.com/quic-go/quic-go v0.51.0 // indirect
|
||||||
github.com/refraction-networking/utls v1.6.7 // indirect
|
github.com/refraction-networking/utls v1.6.7 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
||||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||||
go.uber.org/mock v0.5.1 // indirect
|
go.uber.org/mock v0.5.1 // indirect
|
||||||
|
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dario.cat/mergo v1.0.0 // indirect
|
dario.cat/mergo v1.0.0 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.1 // indirect
|
github.com/Microsoft/go-winio v0.6.1
|
||||||
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect
|
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect
|
||||||
github.com/RoaringBitmap/roaring v1.9.4 // indirect
|
github.com/RoaringBitmap/roaring v1.9.4 // indirect
|
||||||
github.com/acomagu/bufpipe v1.0.4 // indirect
|
github.com/acomagu/bufpipe v1.0.4 // indirect
|
||||||
github.com/ajwerner/btree v0.0.0-20211221152037-f427b3e689c0 // indirect
|
|
||||||
github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
|
github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
|
||||||
github.com/anacrolix/chansync v0.6.0 // indirect
|
github.com/anacrolix/chansync v0.7.0 // indirect
|
||||||
github.com/anacrolix/dht/v2 v2.22.0 // indirect
|
github.com/anacrolix/dht/v2 v2.23.0 // indirect
|
||||||
github.com/anacrolix/envpprof v1.4.0 // indirect
|
github.com/anacrolix/envpprof v1.5.0 // indirect
|
||||||
github.com/anacrolix/generics v0.0.3-0.20240902042256-7fb2702ef0ca // indirect
|
github.com/anacrolix/generics v0.2.0 // indirect
|
||||||
github.com/anacrolix/go-libutp v1.3.1 // indirect
|
github.com/anacrolix/go-libutp v1.3.2 // indirect
|
||||||
github.com/anacrolix/log v0.16.0 // indirect
|
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb // indirect
|
||||||
github.com/anacrolix/missinggo v1.3.0 // indirect
|
github.com/anacrolix/missinggo v1.3.0 // indirect
|
||||||
github.com/anacrolix/missinggo/perf v1.0.0 // indirect
|
github.com/anacrolix/missinggo/perf v1.0.0 // indirect
|
||||||
github.com/anacrolix/mmsg v1.1.1 // indirect
|
github.com/anacrolix/mmsg v1.1.1 // indirect
|
||||||
github.com/anacrolix/multiless v0.4.0 // indirect
|
github.com/anacrolix/multiless v0.4.0 // indirect
|
||||||
github.com/anacrolix/stm v0.5.0 // indirect
|
github.com/anacrolix/stm v0.5.0 // indirect
|
||||||
github.com/anacrolix/sync v0.5.3 // indirect
|
github.com/anacrolix/sync v0.6.0 // indirect
|
||||||
github.com/anacrolix/upnp v0.1.4 // indirect
|
github.com/anacrolix/upnp v0.1.4 // indirect
|
||||||
github.com/anacrolix/utp v0.2.0 // indirect
|
github.com/anacrolix/utp v0.2.0 // indirect
|
||||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
github.com/benbjohnson/immutable v0.4.3 // indirect
|
github.com/benbjohnson/immutable v0.4.3 // indirect
|
||||||
github.com/bits-and-blooms/bitset v1.15.0 // indirect
|
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
|
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
|
||||||
github.com/cespare/xxhash v1.1.0 // indirect
|
github.com/cespare/xxhash v1.1.0 // indirect
|
||||||
github.com/cloudflare/circl v1.6.1 // indirect
|
github.com/cloudflare/circl v1.6.1 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/edsrzf/mmap-go v1.2.0 // indirect
|
github.com/edsrzf/mmap-go v1.2.0 // indirect
|
||||||
github.com/emirpasic/gods v1.18.1 // indirect
|
github.com/emirpasic/gods v1.18.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.1 // indirect
|
github.com/felixge/httpsnoop v1.0.1 // indirect
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||||
github.com/go-git/go-billy/v5 v5.4.1 // indirect
|
github.com/go-git/go-billy/v5 v5.4.1 // indirect
|
||||||
github.com/go-llsqlite/adapter v0.1.0 // indirect
|
github.com/go-llsqlite/adapter v0.2.0 // indirect
|
||||||
github.com/go-llsqlite/crawshaw v0.5.5 // indirect
|
github.com/go-llsqlite/crawshaw v0.6.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/google/btree v1.1.3 // indirect
|
github.com/google/btree v1.1.3 // indirect
|
||||||
github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4 // indirect
|
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/huandu/xstrings v1.5.0 // indirect
|
github.com/huandu/xstrings v1.5.0 // indirect
|
||||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/minio/sha256-simd v1.0.1 // indirect
|
github.com/minio/sha256-simd v1.0.1 // indirect
|
||||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||||
github.com/mschoch/smat v0.2.0 // indirect
|
github.com/mschoch/smat v0.2.0 // indirect
|
||||||
github.com/multiformats/go-multihash v0.2.3 // indirect
|
github.com/multiformats/go-multihash v0.2.3 // indirect
|
||||||
github.com/multiformats/go-varint v0.0.7 // indirect
|
github.com/multiformats/go-varint v0.1.0 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/pion/datachannel v1.5.9 // indirect
|
github.com/pion/datachannel v1.6.0 // indirect
|
||||||
github.com/pion/dtls/v3 v3.0.4 // indirect
|
github.com/pion/dtls/v3 v3.0.10 // indirect
|
||||||
github.com/pion/ice/v4 v4.0.2 // indirect
|
github.com/pion/ice/v4 v4.2.0 // indirect
|
||||||
github.com/pion/interceptor v0.1.37 // indirect
|
github.com/pion/interceptor v0.1.42 // indirect
|
||||||
github.com/pion/logging v0.2.2 // indirect
|
github.com/pion/logging v0.2.4 // indirect
|
||||||
github.com/pion/mdns/v2 v2.0.7 // indirect
|
github.com/pion/mdns/v2 v2.1.0 // indirect
|
||||||
github.com/pion/randutil v0.1.0 // indirect
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
github.com/pion/rtcp v1.2.14 // indirect
|
github.com/pion/rtcp v1.2.16 // indirect
|
||||||
github.com/pion/rtp v1.8.9 // indirect
|
github.com/pion/rtp v1.10.0 // indirect
|
||||||
github.com/pion/sctp v1.8.33 // indirect
|
github.com/pion/sctp v1.9.1 // indirect
|
||||||
github.com/pion/sdp/v3 v3.0.9 // indirect
|
github.com/pion/sdp/v3 v3.0.17 // indirect
|
||||||
github.com/pion/srtp/v3 v3.0.4 // indirect
|
github.com/pion/srtp/v3 v3.0.10 // indirect
|
||||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
github.com/pion/stun/v3 v3.1.1 // indirect
|
||||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
github.com/pion/turn/v4 v4.1.4 // indirect
|
||||||
github.com/pion/turn/v4 v4.0.0 // indirect
|
github.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7 // indirect
|
||||||
github.com/pion/webrtc/v4 v4.0.2 // indirect
|
|
||||||
github.com/pjbgf/sha1cd v0.3.0 // indirect
|
github.com/pjbgf/sha1cd v0.3.0 // indirect
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
|
||||||
github.com/protolambda/ctxlock v0.1.0 // indirect
|
github.com/protolambda/ctxlock v0.1.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/sergi/go-diff v1.1.0 // indirect
|
github.com/sergi/go-diff v1.1.0 // indirect
|
||||||
github.com/shirou/gopsutil/v4 v4.24.12
|
|
||||||
github.com/skeema/knownhosts v1.2.0 // indirect
|
github.com/skeema/knownhosts v1.2.0 // indirect
|
||||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||||
github.com/tidwall/btree v1.7.0 // indirect
|
github.com/tidwall/btree v1.8.1 // indirect
|
||||||
github.com/wlynxg/anet v0.0.5 // indirect
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
go.opentelemetry.io/otel v1.32.0 // indirect
|
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.32.0 // indirect
|
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.32.0 // indirect
|
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||||
golang.org/x/crypto v0.37.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/mod v0.24.0 // indirect
|
golang.org/x/mod v0.31.0 // indirect
|
||||||
golang.org/x/net v0.39.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sys v0.32.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.24.0 // indirect
|
golang.org/x/text v0.33.0
|
||||||
golang.org/x/time v0.8.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
golang.org/x/tools v0.32.0 // indirect
|
golang.org/x/tools v0.40.0 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
lukechampine.com/blake3 v1.3.0 // indirect
|
lukechampine.com/blake3 v1.4.1 // indirect
|
||||||
modernc.org/libc v1.61.0 // indirect
|
modernc.org/libc v1.67.4 // indirect
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.8.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.33.1 // indirect
|
modernc.org/sqlite v1.43.0 // indirect
|
||||||
zombiezen.com/go/sqlite v1.4.0 // indirect
|
zombiezen.com/go/sqlite v1.4.2 // indirect
|
||||||
)
|
)
|
||||||
@@ -1,21 +1,23 @@
|
|||||||
package fetcher
|
package fetcher
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/GopeedLab/gopeed/internal/controller"
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/internal/controller"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fetcher defines the interface for a download protocol.
|
// Fetcher defines the interface for a download protocol.
|
||||||
// Each download task will have a corresponding Fetcher instance for the management of the download task
|
// Each download task will have a corresponding Fetcher instance for the management of the download task
|
||||||
type Fetcher interface {
|
type Fetcher interface {
|
||||||
Setup(ctl *controller.Controller)
|
Setup(ctl *controller.Controller)
|
||||||
// Resolve resource info from request
|
Resolve(req *base.Request, opts *base.Options) error
|
||||||
Resolve(req *base.Request) error
|
|
||||||
// Create ready to download, but not started
|
|
||||||
Create(opts *base.Options) error
|
|
||||||
Start() error
|
Start() error
|
||||||
|
// Patch modifies task-specific data based on the protocol.
|
||||||
|
// For HTTP: can modify Request info (URL, headers, etc.)
|
||||||
|
// For BT: can modify SelectFiles (via opts.SelectFiles)
|
||||||
|
Patch(req *base.Request, opts *base.Options) error
|
||||||
Pause() error
|
Pause() error
|
||||||
Close() error
|
Close() error
|
||||||
|
|
||||||
@@ -125,6 +127,21 @@ type FetcherManager interface {
|
|||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StatefulFetcherManager is an optional extension for protocols that keep
|
||||||
|
// shared client state outside individual task fetchers.
|
||||||
|
type StatefulFetcherManager interface {
|
||||||
|
SetStateStore(store ProtocolStateStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProtocolStateStore persists shared protocol state for a fetcher manager.
|
||||||
|
// Downloader provides the concrete storage backend, while the protocol decides
|
||||||
|
// when state should be loaded or flushed.
|
||||||
|
type ProtocolStateStore interface {
|
||||||
|
Load(v any) (bool, error)
|
||||||
|
Save(v any) error
|
||||||
|
Delete() error
|
||||||
|
}
|
||||||
|
|
||||||
type DefaultFetcher struct {
|
type DefaultFetcher struct {
|
||||||
Ctl *controller.Controller
|
Ctl *controller.Controller
|
||||||
Meta *FetcherMeta
|
Meta *FetcherMeta
|
||||||
|
|||||||
@@ -20,13 +20,13 @@ import (
|
|||||||
"github.com/GopeedLab/gopeed/pkg/util"
|
"github.com/GopeedLab/gopeed/pkg/util"
|
||||||
"github.com/anacrolix/torrent"
|
"github.com/anacrolix/torrent"
|
||||||
"github.com/anacrolix/torrent/metainfo"
|
"github.com/anacrolix/torrent/metainfo"
|
||||||
|
"github.com/anacrolix/torrent/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
cfg *torrent.ClientConfig
|
||||||
client *torrent.Client
|
client *torrent.Client
|
||||||
lock sync.Mutex
|
lock sync.Mutex
|
||||||
torrentDirMap = make(map[string]string)
|
|
||||||
ftMap = make(map[string]*fileTorrentImpl)
|
|
||||||
closeCtx context.Context
|
closeCtx context.Context
|
||||||
closeFunc func()
|
closeFunc func()
|
||||||
)
|
)
|
||||||
@@ -71,21 +71,12 @@ func (f *Fetcher) initClient() (err error) {
|
|||||||
closeCtx, closeFunc = context.WithCancel(context.Background())
|
closeCtx, closeFunc = context.WithCancel(context.Background())
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := torrent.NewDefaultClientConfig()
|
cfg = torrent.NewDefaultClientConfig()
|
||||||
cfg.Seed = true
|
cfg.Seed = true
|
||||||
cfg.Bep20 = fmt.Sprintf("-GP%s-", parseBep20())
|
cfg.Bep20 = fmt.Sprintf("-GP%s-", parseBep20())
|
||||||
cfg.ExtendedHandshakeClientVersion = fmt.Sprintf("Gopeed %s", base.Version)
|
cfg.ExtendedHandshakeClientVersion = fmt.Sprintf("Gopeed %s", base.Version)
|
||||||
cfg.ListenPort = f.config.ListenPort
|
cfg.ListenPort = f.config.ListenPort
|
||||||
cfg.HTTPProxy = f.ctl.GetProxy(f.meta.Req.Proxy)
|
cfg.HTTPProxy = f.ctl.GetProxy(f.meta.Req.Proxy)
|
||||||
cfg.DefaultStorage = newFileOpts(newFileClientOpts{
|
|
||||||
ClientBaseDir: cfg.DataDir,
|
|
||||||
HandleFileTorrent: func(infoHash metainfo.Hash, ft *fileTorrentImpl) {
|
|
||||||
if dir, ok := torrentDirMap[infoHash.String()]; ok {
|
|
||||||
ft.setTorrentDir(dir)
|
|
||||||
}
|
|
||||||
ftMap[infoHash.String()] = ft
|
|
||||||
},
|
|
||||||
})
|
|
||||||
dnsResolver := &DnsCacheResolver{RefreshTimeout: 5 * time.Minute}
|
dnsResolver := &DnsCacheResolver{RefreshTimeout: 5 * time.Minute}
|
||||||
cfg.TrackerDialContext = dnsResolver.DialContext
|
cfg.TrackerDialContext = dnsResolver.DialContext
|
||||||
client, err = torrent.NewClient(cfg)
|
client, err = torrent.NewClient(cfg)
|
||||||
@@ -100,8 +91,12 @@ func (f *Fetcher) initClient() (err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fetcher) Resolve(req *base.Request) error {
|
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
||||||
f.meta.Req = req
|
f.meta.Req = req
|
||||||
|
f.meta.Opts = opts
|
||||||
|
if f.meta.Opts == nil {
|
||||||
|
f.meta.Opts = &base.Options{}
|
||||||
|
}
|
||||||
if err := f.addTorrent(req, false); err != nil {
|
if err := f.addTorrent(req, false); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -109,23 +104,13 @@ func (f *Fetcher) Resolve(req *base.Request) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fetcher) Create(opts *base.Options) (err error) {
|
|
||||||
f.meta.Opts = opts
|
|
||||||
if f.meta.Res != nil {
|
|
||||||
torrentDirMap[f.meta.Res.Hash] = opts.Path
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *Fetcher) Start() (err error) {
|
func (f *Fetcher) Start() (err error) {
|
||||||
if !f.torrentReady.Load() {
|
if !f.torrentReady.Load() {
|
||||||
if err = f.addTorrent(f.meta.Req, false); err != nil {
|
if err = f.addTorrent(f.meta.Req, false); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ft, ok := ftMap[f.meta.Res.Hash]; ok {
|
|
||||||
ft.setTorrentDir(f.meta.Opts.Path)
|
|
||||||
}
|
|
||||||
files := f.torrent.Files()
|
files := f.torrent.Files()
|
||||||
// If the user does not specify the file to download, all files will be downloaded by default
|
// If the user does not specify the file to download, all files will be downloaded by default
|
||||||
if f.data.Progress == nil {
|
if f.data.Progress == nil {
|
||||||
@@ -145,12 +130,12 @@ func (f *Fetcher) Start() (err error) {
|
|||||||
file.Download()
|
file.Download()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
f.torrent.AllowDataDownload()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fetcher) Pause() (err error) {
|
func (f *Fetcher) Pause() (err error) {
|
||||||
f.torrentReady.Store(false)
|
f.torrent.DisallowDataDownload()
|
||||||
f.safeDrop()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +232,59 @@ func (f *Fetcher) isDone() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Patch modifies the BT task settings.
|
||||||
|
// Invalid file indices are silently ignored.
|
||||||
|
func (f *Fetcher) Patch(req *base.Request, opts *base.Options) error {
|
||||||
|
if opts == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.SelectFiles != nil {
|
||||||
|
selectFiles := opts.SelectFiles
|
||||||
|
|
||||||
|
// Get file count from resource metadata
|
||||||
|
fileCount := 0
|
||||||
|
if f.meta.Res != nil {
|
||||||
|
fileCount = len(f.meta.Res.Files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out invalid indices (silently ignore)
|
||||||
|
validSelectFiles := make([]int, 0, len(selectFiles))
|
||||||
|
for _, idx := range selectFiles {
|
||||||
|
if idx >= 0 && idx < fileCount {
|
||||||
|
validSelectFiles = append(validSelectFiles, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.torrent != nil {
|
||||||
|
files := f.torrent.Files()
|
||||||
|
|
||||||
|
// Cancel all current file downloads first
|
||||||
|
f.torrent.CancelPieces(0, f.torrent.NumPieces())
|
||||||
|
|
||||||
|
// Apply new file selection
|
||||||
|
if len(validSelectFiles) == len(files) {
|
||||||
|
f.torrent.DownloadAll()
|
||||||
|
} else {
|
||||||
|
for _, selectIndex := range validSelectFiles {
|
||||||
|
file := files[selectIndex]
|
||||||
|
file.Download()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f.meta.Opts.SelectFiles = validSelectFiles
|
||||||
|
// Recalculate the resource size based on new selection
|
||||||
|
if f.meta.Res != nil {
|
||||||
|
f.meta.Res.CalcSize(validSelectFiles)
|
||||||
|
}
|
||||||
|
// Reset progress tracking for new file selection
|
||||||
|
f.data.Progress = make(fetcher.Progress, len(validSelectFiles))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Fetcher) updateRes() {
|
func (f *Fetcher) updateRes() {
|
||||||
res := &base.Resource{
|
res := &base.Resource{
|
||||||
Range: true,
|
Range: true,
|
||||||
@@ -361,8 +399,12 @@ func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
|
|||||||
}
|
}
|
||||||
schema := util.ParseSchema(req.URL)
|
schema := util.ParseSchema(req.URL)
|
||||||
privateTorrent := false
|
privateTorrent := false
|
||||||
|
var spec *torrent.TorrentSpec
|
||||||
if schema == "MAGNET" {
|
if schema == "MAGNET" {
|
||||||
f.torrent, err = client.AddMagnet(req.URL)
|
spec, err = torrent.TorrentSpecFromMagnetUri(req.URL)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
var reader io.Reader
|
var reader io.Reader
|
||||||
if schema == "FILE" {
|
if schema == "FILE" {
|
||||||
@@ -399,8 +441,18 @@ func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
|
|||||||
if info.Private != nil && *info.Private {
|
if info.Private != nil && *info.Private {
|
||||||
privateTorrent = true
|
privateTorrent = true
|
||||||
}
|
}
|
||||||
f.torrent, err = client.AddTorrent(metaInfo)
|
spec, err = torrent.TorrentSpecFromMetaInfoErr(metaInfo)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
spec.Storage = storage.NewFileOpts(storage.NewFileClientOpts{
|
||||||
|
ClientBaseDir: cfg.DataDir,
|
||||||
|
TorrentDirMaker: func(baseDir string, info *metainfo.Info, infoHash metainfo.Hash) string {
|
||||||
|
return f.meta.Opts.Path
|
||||||
|
},
|
||||||
|
})
|
||||||
|
f.torrent, _, err = client.AddTorrentSpec(spec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
|
|||||||
dataUri := "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(buf)
|
dataUri := "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(buf)
|
||||||
err = fetcher.Resolve(&base.Request{
|
err = fetcher.Resolve(&base.Request{
|
||||||
URL: dataUri,
|
URL: dataUri,
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
|||||||
"udp://tracker.bitsearch.to:1337/announce",
|
"udp://tracker.bitsearch.to:1337/announce",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -111,7 +111,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
|||||||
"udp://tracker.bitsearch.to:1337/announce",
|
"udp://tracker.bitsearch.to:1337/announce",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -145,7 +145,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
|||||||
t.Run("Resolve Unclean Torrent", func(t *testing.T) {
|
t.Run("Resolve Unclean Torrent", func(t *testing.T) {
|
||||||
err := fetcher.Resolve(&base.Request{
|
err := fetcher.Resolve(&base.Request{
|
||||||
URL: "./testdata/test.unclean.torrent",
|
URL: "./testdata/test.unclean.torrent",
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Resolve Unclean Torrent Resolve() got = %v, want nil", err)
|
t.Errorf("Resolve Unclean Torrent Resolve() got = %v, want nil", err)
|
||||||
}
|
}
|
||||||
@@ -156,7 +156,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
|||||||
uri := "file:///" + file
|
uri := "file:///" + file
|
||||||
err := fetcher.Resolve(&base.Request{
|
err := fetcher.Resolve(&base.Request{
|
||||||
URL: uri,
|
URL: uri,
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Resolve file scheme Torrent Resolve() got = %v, want nil", err)
|
t.Errorf("Resolve file scheme Torrent Resolve() got = %v, want nil", err)
|
||||||
}
|
}
|
||||||
@@ -239,3 +239,130 @@ func buildConfigFetcher(proxyConfig *base.DownloaderProxyConfig) fetcher.Fetcher
|
|||||||
fetcher.Setup(newController)
|
fetcher.Setup(newController)
|
||||||
return fetcher
|
return fetcher
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFetcher_Patch tests the Patch functionality for BT fetcher.
|
||||||
|
// It tests modifying selected files after Resolve (without downloading).
|
||||||
|
func TestFetcher_Patch(t *testing.T) {
|
||||||
|
f := buildFetcher()
|
||||||
|
|
||||||
|
// Resolve a multi-file torrent
|
||||||
|
err := f.Resolve(&base.Request{
|
||||||
|
URL: "./testdata/test.torrent",
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify initial state: 3 files, all selected by default
|
||||||
|
meta := f.Meta()
|
||||||
|
if len(meta.Res.Files) != 3 {
|
||||||
|
t.Fatalf("Expected 3 files, got %d", len(meta.Res.Files))
|
||||||
|
}
|
||||||
|
if len(meta.Opts.SelectFiles) != 3 {
|
||||||
|
t.Fatalf("Expected 3 selected files, got %d", len(meta.Opts.SelectFiles))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total size of all files: 107484864
|
||||||
|
totalSize := int64(107484864)
|
||||||
|
if meta.Res.Size != totalSize {
|
||||||
|
t.Fatalf("Expected total size %d, got %d", totalSize, meta.Res.Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Patch with valid indices", func(t *testing.T) {
|
||||||
|
// Select only file 0 and 2 (c.txt and a.txt)
|
||||||
|
err := f.Patch(nil, &base.Options{
|
||||||
|
SelectFiles: []int{0, 2},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Patch failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := f.Meta()
|
||||||
|
if !reflect.DeepEqual(meta.Opts.SelectFiles, []int{0, 2}) {
|
||||||
|
t.Errorf("Expected SelectFiles [0, 2], got %v", meta.Opts.SelectFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size should be recalculated: c.txt (98501754) + a.txt (78114) = 98579868
|
||||||
|
expectedSize := int64(98501754 + 78114)
|
||||||
|
if meta.Res.Size != expectedSize {
|
||||||
|
t.Errorf("Expected size %d, got %d", expectedSize, meta.Res.Size)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Patch with invalid indices are silently ignored", func(t *testing.T) {
|
||||||
|
// Mix of valid (0, 1) and invalid (-1, 5, 100) indices
|
||||||
|
err := f.Patch(nil, &base.Options{
|
||||||
|
SelectFiles: []int{-1, 0, 5, 1, 100},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Patch should not return error for invalid indices: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := f.Meta()
|
||||||
|
// Only valid indices 0 and 1 should remain
|
||||||
|
if !reflect.DeepEqual(meta.Opts.SelectFiles, []int{0, 1}) {
|
||||||
|
t.Errorf("Expected SelectFiles [0, 1], got %v", meta.Opts.SelectFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size should be: c.txt (98501754) + b.txt (8904996) = 107406750
|
||||||
|
expectedSize := int64(98501754 + 8904996)
|
||||||
|
if meta.Res.Size != expectedSize {
|
||||||
|
t.Errorf("Expected size %d, got %d", expectedSize, meta.Res.Size)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Patch with all invalid indices results in empty selection", func(t *testing.T) {
|
||||||
|
err := f.Patch(nil, &base.Options{
|
||||||
|
SelectFiles: []int{-5, 10, 999},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Patch should not return error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := f.Meta()
|
||||||
|
if len(meta.Opts.SelectFiles) != 0 {
|
||||||
|
t.Errorf("Expected empty SelectFiles, got %v", meta.Opts.SelectFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: CalcSize with empty selectFiles calculates total size of all files
|
||||||
|
// This is by design - empty selection in CalcSize means "all files"
|
||||||
|
// But SelectFiles being empty means no files are selected for download
|
||||||
|
if meta.Res.Size != totalSize {
|
||||||
|
t.Errorf("Expected size %d (CalcSize with empty slice = all files), got %d", totalSize, meta.Res.Size)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Patch with nil opts does nothing", func(t *testing.T) {
|
||||||
|
// First set a known state
|
||||||
|
f.Patch(nil, &base.Options{SelectFiles: []int{1}})
|
||||||
|
prevSelectFiles := f.Meta().Opts.SelectFiles
|
||||||
|
|
||||||
|
// Patch with nil opts
|
||||||
|
err := f.Patch(nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Patch with nil opts should not fail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should remain unchanged
|
||||||
|
if !reflect.DeepEqual(f.Meta().Opts.SelectFiles, prevSelectFiles) {
|
||||||
|
t.Errorf("SelectFiles should remain unchanged after nil opts Patch")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Patch progress array is resized", func(t *testing.T) {
|
||||||
|
btFetcher := f.(*Fetcher)
|
||||||
|
// Initialize progress array
|
||||||
|
btFetcher.data.Progress = make(fetcher.Progress, 3)
|
||||||
|
|
||||||
|
err := f.Patch(nil, &base.Options{
|
||||||
|
SelectFiles: []int{0, 2},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Patch failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(btFetcher.data.Progress) != 2 {
|
||||||
|
t.Errorf("Expected Progress length 2, got %d", len(btFetcher.data.Progress))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
// modify from github.com/anacrolix/torrent/storage/file.go
|
|
||||||
|
|
||||||
package bt
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/anacrolix/torrent/storage"
|
|
||||||
|
|
||||||
"github.com/anacrolix/missinggo/v2"
|
|
||||||
|
|
||||||
"github.com/anacrolix/torrent/common"
|
|
||||||
"github.com/anacrolix/torrent/metainfo"
|
|
||||||
"github.com/anacrolix/torrent/segments"
|
|
||||||
)
|
|
||||||
|
|
||||||
// File-based storage for torrents, that isn't yet bound to a particular torrent.
|
|
||||||
type fileClientImpl struct {
|
|
||||||
opts newFileClientOpts
|
|
||||||
}
|
|
||||||
|
|
||||||
type newFileClientOpts struct {
|
|
||||||
// The base directory for all downloads.
|
|
||||||
ClientBaseDir string
|
|
||||||
HandleFileTorrent func(infoHash metainfo.Hash, ft *fileTorrentImpl)
|
|
||||||
FilePathMaker storage.FilePathMaker
|
|
||||||
PieceCompletion storage.PieceCompletion
|
|
||||||
}
|
|
||||||
|
|
||||||
func newFileOpts(opts newFileClientOpts) storage.ClientImplCloser {
|
|
||||||
if opts.FilePathMaker == nil {
|
|
||||||
opts.FilePathMaker = func(opts storage.FilePathMakerOpts) string {
|
|
||||||
var parts []string
|
|
||||||
if opts.Info.Length == 0 || opts.File.Path == nil {
|
|
||||||
parts = append(parts, opts.Info.Name)
|
|
||||||
}
|
|
||||||
return filepath.Join(append(parts, opts.File.Path...)...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if opts.PieceCompletion == nil {
|
|
||||||
ret, err := storage.NewDefaultPieceCompletionForDir(opts.ClientBaseDir)
|
|
||||||
if err != nil {
|
|
||||||
ret = storage.NewMapPieceCompletion()
|
|
||||||
}
|
|
||||||
opts.PieceCompletion = ret
|
|
||||||
}
|
|
||||||
return fileClientImpl{opts}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs fileClientImpl) Close() error {
|
|
||||||
return fs.opts.PieceCompletion.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs fileClientImpl) OpenTorrent(ctx context.Context, info *metainfo.Info, infoHash metainfo.Hash) (_ storage.TorrentImpl, err error) {
|
|
||||||
upvertedFiles := info.UpvertedFiles()
|
|
||||||
files := make([]file, 0, len(upvertedFiles))
|
|
||||||
for _, fileInfo := range upvertedFiles {
|
|
||||||
filePath := filepath.Join("", fs.opts.FilePathMaker(storage.FilePathMakerOpts{
|
|
||||||
Info: info,
|
|
||||||
File: &fileInfo,
|
|
||||||
}))
|
|
||||||
f := file{
|
|
||||||
rawPath: filePath,
|
|
||||||
path: filePath,
|
|
||||||
length: fileInfo.Length,
|
|
||||||
}
|
|
||||||
if f.length == 0 {
|
|
||||||
err = CreateNativeZeroLengthFile(f.path)
|
|
||||||
if err != nil {
|
|
||||||
err = fmt.Errorf("creating zero length file: %w", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
files = append(files, f)
|
|
||||||
}
|
|
||||||
t := &fileTorrentImpl{
|
|
||||||
files,
|
|
||||||
segments.NewIndex(common.LengthIterFromUpvertedFiles(upvertedFiles)),
|
|
||||||
infoHash,
|
|
||||||
fs.opts.PieceCompletion,
|
|
||||||
}
|
|
||||||
fs.opts.HandleFileTorrent(infoHash, t)
|
|
||||||
return storage.TorrentImpl{
|
|
||||||
Piece: t.Piece,
|
|
||||||
Close: t.Close,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type file struct {
|
|
||||||
// The safe, OS-local file path.
|
|
||||||
rawPath string
|
|
||||||
path string
|
|
||||||
length int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type fileTorrentImpl struct {
|
|
||||||
files []file
|
|
||||||
segmentLocater segments.Index
|
|
||||||
infoHash metainfo.Hash
|
|
||||||
completion storage.PieceCompletion
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fts *fileTorrentImpl) Piece(p metainfo.Piece) storage.PieceImpl {
|
|
||||||
// Create a view onto the file-based torrent storage.
|
|
||||||
_io := fileTorrentImplIO{fts}
|
|
||||||
// Return the appropriate segments of this.
|
|
||||||
return &filePieceImpl{
|
|
||||||
fts,
|
|
||||||
p,
|
|
||||||
missinggo.NewSectionWriter(_io, p.Offset(), p.Length()),
|
|
||||||
io.NewSectionReader(_io, p.Offset(), p.Length()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fts *fileTorrentImpl) Close() error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fts *fileTorrentImpl) setTorrentDir(dir string) {
|
|
||||||
for i, f := range fts.files {
|
|
||||||
fts.files[i].path = filepath.Join(dir, f.rawPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A helper to create zero-length files which won't appear for file-orientated storage since no
|
|
||||||
// writes will ever occur to them (no torrent data is associated with a zero-length file). The
|
|
||||||
// caller should make sure the file name provided is safe/sanitized.
|
|
||||||
func CreateNativeZeroLengthFile(name string) error {
|
|
||||||
os.MkdirAll(filepath.Dir(name), 0o777)
|
|
||||||
var f io.Closer
|
|
||||||
f, err := os.Create(name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return f.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exposes file-based storage of a torrent, as one big ReadWriterAt.
|
|
||||||
type fileTorrentImplIO struct {
|
|
||||||
fts *fileTorrentImpl
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns EOF on short or missing file.
|
|
||||||
func (fst *fileTorrentImplIO) readFileAt(file file, b []byte, off int64) (n int, err error) {
|
|
||||||
f, err := os.Open(file.path)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
// File missing is treated the same as a short file.
|
|
||||||
err = io.EOF
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
// Limit the read to within the expected bounds of this file.
|
|
||||||
if int64(len(b)) > file.length-off {
|
|
||||||
b = b[:file.length-off]
|
|
||||||
}
|
|
||||||
for off < file.length && len(b) != 0 {
|
|
||||||
n1, err1 := f.ReadAt(b, off)
|
|
||||||
b = b[n1:]
|
|
||||||
n += n1
|
|
||||||
off += int64(n1)
|
|
||||||
if n1 == 0 {
|
|
||||||
err = err1
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only returns EOF at the end of the torrent. Premature EOF is ErrUnexpectedEOF.
|
|
||||||
func (fst fileTorrentImplIO) ReadAt(b []byte, off int64) (n int, err error) {
|
|
||||||
fst.fts.segmentLocater.Locate(segments.Extent{off, int64(len(b))}, func(i int, e segments.Extent) bool {
|
|
||||||
n1, err1 := fst.readFileAt(fst.fts.files[i], b[:e.Length], e.Start)
|
|
||||||
n += n1
|
|
||||||
b = b[n1:]
|
|
||||||
err = err1
|
|
||||||
return err == nil // && int64(n1) == e.Length
|
|
||||||
})
|
|
||||||
if len(b) != 0 && err == nil {
|
|
||||||
err = io.EOF
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fst fileTorrentImplIO) WriteAt(p []byte, off int64) (n int, err error) {
|
|
||||||
// log.Printf("write at %v: %v bytes", off, len(p))
|
|
||||||
fst.fts.segmentLocater.Locate(segments.Extent{off, int64(len(p))}, func(i int, e segments.Extent) bool {
|
|
||||||
name := fst.fts.files[i].path
|
|
||||||
os.MkdirAll(filepath.Dir(name), 0o777)
|
|
||||||
var f *os.File
|
|
||||||
f, err = os.OpenFile(name, os.O_WRONLY|os.O_CREATE, 0o666)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var n1 int
|
|
||||||
n1, err = f.WriteAt(p[:e.Length], e.Start)
|
|
||||||
// log.Printf("%v %v wrote %v: %v", i, e, n1, err)
|
|
||||||
closeErr := f.Close()
|
|
||||||
n += n1
|
|
||||||
p = p[n1:]
|
|
||||||
if err == nil {
|
|
||||||
err = closeErr
|
|
||||||
}
|
|
||||||
if err == nil && int64(n1) != e.Length {
|
|
||||||
err = io.ErrShortWrite
|
|
||||||
}
|
|
||||||
return err == nil
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
// modify from github.com/anacrolix/torrent/storage/file_misc.go
|
|
||||||
|
|
||||||
package bt
|
|
||||||
|
|
||||||
import "github.com/anacrolix/torrent/metainfo"
|
|
||||||
|
|
||||||
type requiredLength struct {
|
|
||||||
fileIndex int
|
|
||||||
length int64
|
|
||||||
}
|
|
||||||
|
|
||||||
func extentCompleteRequiredLengths(info *metainfo.Info, off, n int64) (ret []requiredLength) {
|
|
||||||
if n == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for i, fi := range info.UpvertedFiles() {
|
|
||||||
if off >= fi.Length {
|
|
||||||
off -= fi.Length
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
n1 := n
|
|
||||||
if off+n1 > fi.Length {
|
|
||||||
n1 = fi.Length - off
|
|
||||||
}
|
|
||||||
ret = append(ret, requiredLength{
|
|
||||||
fileIndex: i,
|
|
||||||
length: off + n1,
|
|
||||||
})
|
|
||||||
n -= n1
|
|
||||||
if n == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
off = 0
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
// modify from github.com/anacrolix/torrent/storage/file_piece.go
|
|
||||||
|
|
||||||
package bt
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/anacrolix/torrent/metainfo"
|
|
||||||
"github.com/anacrolix/torrent/storage"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
type filePieceImpl struct {
|
|
||||||
*fileTorrentImpl
|
|
||||||
p metainfo.Piece
|
|
||||||
io.WriterAt
|
|
||||||
io.ReaderAt
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ storage.PieceImpl = (*filePieceImpl)(nil)
|
|
||||||
|
|
||||||
func (fs *filePieceImpl) pieceKey() metainfo.PieceKey {
|
|
||||||
return metainfo.PieceKey{InfoHash: fs.infoHash, Index: fs.p.Index()}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs *filePieceImpl) Completion() storage.Completion {
|
|
||||||
c, err := fs.completion.Get(fs.pieceKey())
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("error getting piece completion: %s", err)
|
|
||||||
c.Ok = false
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
verified := true
|
|
||||||
if c.Complete {
|
|
||||||
// If it's allegedly complete, check that its constituent files have the necessary length.
|
|
||||||
for _, fi := range extentCompleteRequiredLengths(fs.p.Info, fs.p.Offset(), fs.p.Length()) {
|
|
||||||
s, err := os.Stat(fs.files[fi.fileIndex].path)
|
|
||||||
if err != nil || s.Size() < fi.length {
|
|
||||||
verified = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !verified {
|
|
||||||
// The completion was wrong, fix it.
|
|
||||||
c.Complete = false
|
|
||||||
fs.completion.Set(fs.pieceKey(), false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs *filePieceImpl) MarkComplete() error {
|
|
||||||
return fs.completion.Set(fs.pieceKey(), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fs *filePieceImpl) MarkNotComplete() error {
|
|
||||||
return fs.completion.Set(fs.pieceKey(), false)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package ed2k
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultServerList = "45.82.80.155:5687,176.123.5.89:4725,85.121.5.137:4232,176.123.2.239:4232,145.239.2.134:4661,91.208.162.87:4232,37.15.61.236:4232"
|
||||||
|
defaultServerMet = "ed2k://|serverlist|http://upd.emule-security.org/server.met|/"
|
||||||
|
defaultNodesDat = "https://upd.emule-security.org/nodes.dat"
|
||||||
|
)
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
ListenPort int `json:"listenPort"`
|
||||||
|
UDPPort int `json:"udpPort"`
|
||||||
|
ServerAddr string `json:"serverAddr"`
|
||||||
|
ServerMet string `json:"serverMet"`
|
||||||
|
NodesDat string `json:"nodesDat"`
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
|||||||
|
package ed2k
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/internal/controller"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testLink = "ed2k://|file|cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso|4630972416|8867C5E54405FF9452225B66EFEE690A|/"
|
||||||
|
|
||||||
|
func TestFetcher_Resolve(t *testing.T) {
|
||||||
|
f := (&FetcherManager{}).Build()
|
||||||
|
f.Setup(controller.NewController())
|
||||||
|
|
||||||
|
err := f.Resolve(&base.Request{URL: testLink}, &base.Options{Path: t.TempDir()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := f.Meta()
|
||||||
|
if meta.Res == nil {
|
||||||
|
t.Fatal("Resolve() resource is nil")
|
||||||
|
}
|
||||||
|
if got, want := meta.Res.Hash, "8867C5E54405FF9452225B66EFEE690A"; got != want {
|
||||||
|
t.Fatalf("Resolve() hash = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
if got, want := meta.Res.Size, int64(4630972416); got != want {
|
||||||
|
t.Fatalf("Resolve() size = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if got, want := meta.Res.Files[0].Name, "cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso"; got != want {
|
||||||
|
t.Fatalf("Resolve() name = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetcherManager_ParseName(t *testing.T) {
|
||||||
|
got := (&FetcherManager{}).ParseName(testLink)
|
||||||
|
want := "cn_windows_10_multi-edition_vl_version_1709_updated_sept_2017_x64_dvd_100090774.iso"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("ParseName() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1612
-558
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,105 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Fetcher Data (for Store/Restore)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type fetcherData struct {
|
||||||
|
Connections []*connection
|
||||||
|
RedirectURL string // Saved redirect URL for resume
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Fetcher Manager
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type FetcherManager struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) Name() string {
|
||||||
|
return "http"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) Filters() []*fetcher.SchemeFilter {
|
||||||
|
return []*fetcher.SchemeFilter{
|
||||||
|
{
|
||||||
|
Type: fetcher.FilterTypeUrl,
|
||||||
|
Pattern: "HTTP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: fetcher.FilterTypeUrl,
|
||||||
|
Pattern: "HTTPS",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) Build() fetcher.Fetcher {
|
||||||
|
return &Fetcher{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) ParseName(u string) string {
|
||||||
|
var name string
|
||||||
|
url, err := url.Parse(u)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
name = path.Base(url.Path)
|
||||||
|
if name == "" || name == "/" || name == "." {
|
||||||
|
name = url.Hostname()
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) AutoRename() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) DefaultConfig() any {
|
||||||
|
return &config{
|
||||||
|
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36",
|
||||||
|
Connections: 16,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
|
||||||
|
_f := f.(*Fetcher)
|
||||||
|
_f.redirectLock.Lock()
|
||||||
|
redirectURL := _f.redirectURL
|
||||||
|
_f.redirectLock.Unlock()
|
||||||
|
return &fetcherData{
|
||||||
|
Connections: _f.connections,
|
||||||
|
RedirectURL: redirectURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) 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 := &FetcherManager{}
|
||||||
|
fetcher := fb.Build().(*Fetcher)
|
||||||
|
fetcher.meta = meta
|
||||||
|
base.ParseReqExtra[fhttp.ReqExtra](fetcher.meta.Req)
|
||||||
|
base.ParseOptExtra[fhttp.OptsExtra](fetcher.meta.Opts)
|
||||||
|
if len(fd.Connections) > 0 {
|
||||||
|
fetcher.connections = fd.Connections
|
||||||
|
}
|
||||||
|
// Restore redirect URL for resume
|
||||||
|
if fd.RedirectURL != "" {
|
||||||
|
fetcher.redirectURL = fd.RedirectURL
|
||||||
|
}
|
||||||
|
return fetcher
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *FetcherManager) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,138 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestParseFilenameWithAmpersand tests the fix for filenames containing & character
|
||||||
|
// Issue: filenames with & are HTML-encoded as & and then truncated at the semicolon
|
||||||
|
func TestParseFilenameWithAmpersand(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
disposition string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "quoted filename with &",
|
||||||
|
disposition: `attachment; filename="查询处理&优化.pptx"`,
|
||||||
|
want: "查询处理&优化.pptx",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unquoted filename with &",
|
||||||
|
disposition: `attachment; filename=test&file.txt`,
|
||||||
|
want: "test&file.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quoted filename with & and extra params",
|
||||||
|
disposition: `attachment; filename="test&file.txt"; charset=utf-8`,
|
||||||
|
want: "test&file.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "filename with multiple HTML entities",
|
||||||
|
disposition: `attachment; filename="test&<>.txt"`,
|
||||||
|
want: "test&<>.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "normal filename without entities",
|
||||||
|
disposition: `attachment; filename="normal.txt"`,
|
||||||
|
want: "normal.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "filename with actual ampersand (no encoding)",
|
||||||
|
disposition: `attachment; filename="test&file.txt"`,
|
||||||
|
want: "test&file.txt",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parseFilename(tt.disposition)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("parseFilename() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindParamValueEnd tests the helper function for finding parameter value boundaries
|
||||||
|
func TestFindParamValueEnd(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "quoted value with semicolon inside",
|
||||||
|
value: `"test&file.txt"; charset=utf-8`,
|
||||||
|
want: 19, // Position of semicolon after closing quote
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quoted value without semicolon after",
|
||||||
|
value: `"test.txt"`,
|
||||||
|
want: -1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unquoted value with semicolon",
|
||||||
|
value: `test.txt; charset=utf-8`,
|
||||||
|
want: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unquoted value without semicolon",
|
||||||
|
value: `test.txt`,
|
||||||
|
want: -1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := findParamValueEnd(tt.value)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("findParamValueEnd() = %d, want %d", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnescapeHTMLEntities tests HTML entity unescaping
|
||||||
|
func TestUnescapeHTMLEntities(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ampersand",
|
||||||
|
in: "test&file.txt",
|
||||||
|
want: "test&file.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "less than and greater than",
|
||||||
|
in: "<test>.txt",
|
||||||
|
want: "<test>.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "quote",
|
||||||
|
in: "test"file.txt",
|
||||||
|
want: "test\"file.txt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple entities",
|
||||||
|
in: "a&b<c>d"e",
|
||||||
|
want: "a&b<c>d\"e",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no entities",
|
||||||
|
in: "normal.txt",
|
||||||
|
want: "normal.txt",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := unescapeHTMLEntities(tt.in)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("unescapeHTMLEntities() = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/util"
|
||||||
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RequestError struct {
|
||||||
|
Code int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRequestError(code int) *RequestError {
|
||||||
|
return &RequestError{Code: code}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (re *RequestError) Error() string {
|
||||||
|
return fmt.Sprintf("http request fail, code:%d", re.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFailureExemptHTTPCode(code int) bool {
|
||||||
|
if code >= 500 && code <= 599 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch code {
|
||||||
|
case 429, 408, 440, 499:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldCountHTTPFailure(err error) bool {
|
||||||
|
var re *RequestError
|
||||||
|
if !errors.As(err, &re) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return !isFailureExemptHTTPCode(re.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractRequestError(err error) *RequestError {
|
||||||
|
var re *RequestError
|
||||||
|
if errors.As(err, &re) {
|
||||||
|
return re
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequest creates an HTTP request using the redirect URL if available.
|
||||||
|
func (f *Fetcher) buildRequest(ctx context.Context, req *base.Request) (httpReq *http.Request, err error) {
|
||||||
|
return f.buildRequestWithURL(ctx, req, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestWithOriginalURL creates an HTTP request using the original URL.
|
||||||
|
// This is used for retrying when the redirect URL has expired.
|
||||||
|
func (f *Fetcher) buildRequestWithOriginalURL(ctx context.Context, req *base.Request) (httpReq *http.Request, err error) {
|
||||||
|
return f.buildRequestWithURL(ctx, req, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestWithURL creates an HTTP request.
|
||||||
|
// If useRedirect is true and a redirect URL exists, it will be used; otherwise the original URL is used.
|
||||||
|
func (f *Fetcher) buildRequestWithURL(ctx context.Context, req *base.Request, useRedirect bool) (httpReq *http.Request, err error) {
|
||||||
|
var reqUrl string
|
||||||
|
f.redirectLock.Lock()
|
||||||
|
if useRedirect && f.redirectURL != "" {
|
||||||
|
reqUrl = f.redirectURL
|
||||||
|
} else {
|
||||||
|
reqUrl = req.URL
|
||||||
|
}
|
||||||
|
f.redirectLock.Unlock()
|
||||||
|
|
||||||
|
var (
|
||||||
|
method string
|
||||||
|
body io.Reader
|
||||||
|
)
|
||||||
|
headers := http.Header{}
|
||||||
|
if req.Extra == nil {
|
||||||
|
method = http.MethodGet
|
||||||
|
} else {
|
||||||
|
extra := req.Extra.(*fhttp.ReqExtra)
|
||||||
|
if extra.Method != "" {
|
||||||
|
method = extra.Method
|
||||||
|
} else {
|
||||||
|
method = http.MethodGet
|
||||||
|
}
|
||||||
|
if len(extra.Header) > 0 {
|
||||||
|
for k, v := range extra.Header {
|
||||||
|
headers.Set(k, strings.TrimSpace(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if extra.Body != "" {
|
||||||
|
body = bytes.NewBufferString(extra.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := headers[base.HttpHeaderUserAgent]; !ok {
|
||||||
|
headers.Set(base.HttpHeaderUserAgent, strings.TrimSpace(f.config.UserAgent))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx != nil {
|
||||||
|
httpReq, err = http.NewRequestWithContext(ctx, method, reqUrl, body)
|
||||||
|
} else {
|
||||||
|
httpReq, err = http.NewRequest(method, reqUrl, body)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpReq.Header = headers
|
||||||
|
if host := headers.Get(base.HttpHeaderHost); host != "" {
|
||||||
|
httpReq.Host = host
|
||||||
|
}
|
||||||
|
return httpReq, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateRedirectURL updates the redirect URL from the response.
|
||||||
|
// This is called when a request using the original URL succeeds after the redirect URL expired.
|
||||||
|
func (f *Fetcher) updateRedirectURL(url string) {
|
||||||
|
f.redirectLock.Lock()
|
||||||
|
f.redirectURL = url
|
||||||
|
f.redirectLock.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasRedirectURL checks if a redirect URL exists and is different from the original URL.
|
||||||
|
func (f *Fetcher) hasRedirectURL() bool {
|
||||||
|
f.redirectLock.Lock()
|
||||||
|
defer f.redirectLock.Unlock()
|
||||||
|
return f.redirectURL != "" && f.redirectURL != f.meta.Req.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRedirectExpiredError checks if the error indicates that the redirect URL may have expired.
|
||||||
|
// This includes 403 (Forbidden), 401 (Unauthorized), 410 (Gone), and network errors.
|
||||||
|
func isRedirectExpiredError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for specific HTTP error codes that might indicate URL expiration
|
||||||
|
if re := extractRequestError(err); re != nil {
|
||||||
|
switch re.Code {
|
||||||
|
case 401, 403, 404, 410:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryFallbackToOriginalURL attempts to make a request using the original URL
|
||||||
|
// when the redirect URL has expired. Returns the response if successful.
|
||||||
|
func (f *Fetcher) tryFallbackToOriginalURL(ctx context.Context, client *http.Client, rangeStart, rangeEnd int64) (*http.Response, error) {
|
||||||
|
httpReq, err := f.buildRequestWithOriginalURL(ctx, f.meta.Req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.meta.Res.Range && rangeEnd > 0 {
|
||||||
|
httpReq.Header.Set(base.HttpHeaderRange,
|
||||||
|
fmt.Sprintf(base.HttpHeaderRangeFormat, rangeStart, rangeEnd))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != base.HttpCodeOK && resp.StatusCode != base.HttpCodePartialContent {
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil, NewRequestError(resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildClient creates an HTTP client with the default connection timeout.
|
||||||
|
// Used for resolve phase where we don't have connection time data yet.
|
||||||
|
func (f *Fetcher) buildClient() *http.Client {
|
||||||
|
return f.buildClientWithTimeout(connectTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildFastFailClient creates an HTTP client with fast-fail timeout.
|
||||||
|
// Uses max(minFastFailTimeout, maxConnTime) for fast-fail retry during download phase.
|
||||||
|
func (f *Fetcher) buildFastFailClient() *http.Client {
|
||||||
|
maxConn := f.maxConnTime.Load()
|
||||||
|
if maxConn == 0 {
|
||||||
|
// No successful connection yet, use default timeout
|
||||||
|
return f.buildClientWithTimeout(connectTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := maxConn
|
||||||
|
if timeout >= minFastFailTimeout {
|
||||||
|
// If greater than minFastFailTimeout, increase by 50% for safety margin
|
||||||
|
timeout = int64(float64(timeout) * 1.5)
|
||||||
|
} else {
|
||||||
|
timeout = minFastFailTimeout
|
||||||
|
}
|
||||||
|
return f.buildClientWithTimeout(time.Duration(timeout))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildClientWithTimeout creates an HTTP client with the specified connection timeout.
|
||||||
|
func (f *Fetcher) buildClientWithTimeout(timeout time.Duration) *http.Client {
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: timeout,
|
||||||
|
}).DialContext,
|
||||||
|
Proxy: f.ctl.GetProxy(f.meta.Req.Proxy),
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
InsecureSkipVerify: f.meta.Req.SkipVerifyCert,
|
||||||
|
},
|
||||||
|
TLSHandshakeTimeout: timeout,
|
||||||
|
}
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
return &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
Jar: jar,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Filename Parsing
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// parseFilename extracts filename from Content-Disposition header
|
||||||
|
func parseFilename(contentDisposition string) string {
|
||||||
|
// Try RFC 5987 extended notation first (filename*=)
|
||||||
|
if filename := parseFilenameExtended(contentDisposition); filename != "" {
|
||||||
|
return filename
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try standard MIME parsing
|
||||||
|
_, params, err := mime.ParseMediaType(contentDisposition)
|
||||||
|
if err == nil {
|
||||||
|
if filename := params["filename"]; filename != "" {
|
||||||
|
return decodeFilenameParam(filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to manual parsing
|
||||||
|
return parseFilenameFallback(contentDisposition)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFilenameExtended handles RFC 5987 extended notation (filename*=)
|
||||||
|
// Format: filename*=charset'language'value (e.g., UTF-8”%E6%B5%8B%E8%AF%95.zip)
|
||||||
|
func parseFilenameExtended(cd string) string {
|
||||||
|
lower := strings.ToLower(cd)
|
||||||
|
idx := strings.Index(lower, "filename*=")
|
||||||
|
if idx == -1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
value := cd[idx+len("filename*="):]
|
||||||
|
|
||||||
|
// Find the end of the value using proper quote handling
|
||||||
|
endIdx := findParamValueEnd(value)
|
||||||
|
if endIdx != -1 {
|
||||||
|
value = value[:endIdx]
|
||||||
|
}
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
|
||||||
|
// Try charset''encoded format (e.g., UTF-8''%E4%B8%AD%E6%96%87.txt)
|
||||||
|
parts := strings.SplitN(value, "''", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
// Use PathUnescape to handle %2B correctly (should decode to +, not space)
|
||||||
|
decoded, err := url.PathUnescape(parts[1])
|
||||||
|
if err == nil {
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try charset'language'encoded format
|
||||||
|
parts = strings.SplitN(value, "'", 3)
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
// Use PathUnescape to handle %2B correctly (should decode to +, not space)
|
||||||
|
decoded, err := url.PathUnescape(parts[2])
|
||||||
|
if err == nil {
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeFilenameParam decodes filename parameter value
|
||||||
|
// Handles HTML entities, MIME encoded-word, URL encoding, and GBK encoding fallback
|
||||||
|
func decodeFilenameParam(filename string) string {
|
||||||
|
// First, unescape HTML entities (e.g., & -> &, < -> <, > -> >)
|
||||||
|
// This must be done before other decoding to handle cases where servers
|
||||||
|
// HTML-encode special characters in filenames
|
||||||
|
filename = unescapeHTMLEntities(filename)
|
||||||
|
|
||||||
|
// Handle RFC 2047 encoded word (=?charset?encoding?text?=)
|
||||||
|
if strings.HasPrefix(filename, "=?") {
|
||||||
|
decoder := new(mime.WordDecoder)
|
||||||
|
normalizedFilename := strings.Replace(filename, "UTF8", "UTF-8", 1)
|
||||||
|
if decoded, err := decoder.Decode(normalizedFilename); err == nil {
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try URL decoding - use PathUnescape for filenames to handle %2B correctly
|
||||||
|
decoded := util.TryUrlPathUnescape(filename)
|
||||||
|
|
||||||
|
// If not valid UTF-8, try GBK decoding (common for Chinese websites)
|
||||||
|
if !utf8.ValidString(decoded) {
|
||||||
|
if gbkDecoded := tryDecodeGBK(decoded); gbkDecoded != "" {
|
||||||
|
return gbkDecoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
// unescapeHTMLEntities unescapes common HTML entities in filenames
|
||||||
|
// This handles cases where servers HTML-encode special characters like & to &
|
||||||
|
func unescapeHTMLEntities(s string) string {
|
||||||
|
// Common HTML entities that might appear in filenames
|
||||||
|
replacements := map[string]string{
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
""": "\"",
|
||||||
|
"'": "'",
|
||||||
|
"'": "'",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := s
|
||||||
|
for entity, char := range replacements {
|
||||||
|
result = strings.ReplaceAll(result, entity, char)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryDecodeGBK attempts to decode string as GBK encoding
|
||||||
|
func tryDecodeGBK(s string) string {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
decoder := simplifiedchinese.GBK.NewDecoder()
|
||||||
|
decoded, err := decoder.Bytes([]byte(s))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
result := string(decoded)
|
||||||
|
if utf8.ValidString(result) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFilenameFallback is a fallback parser for non-standard Content-Disposition
|
||||||
|
func parseFilenameFallback(cd string) string {
|
||||||
|
lower := strings.ToLower(cd)
|
||||||
|
idx := strings.Index(lower, "filename=")
|
||||||
|
if idx == -1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
value := cd[idx+len("filename="):]
|
||||||
|
|
||||||
|
// Find the end of the value using proper quote handling
|
||||||
|
endIdx := findParamValueEnd(value)
|
||||||
|
if endIdx != -1 {
|
||||||
|
value = value[:endIdx]
|
||||||
|
}
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
|
||||||
|
// Remove surrounding quotes
|
||||||
|
if len(value) >= 2 {
|
||||||
|
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
||||||
|
(value[0] == '\'' && value[len(value)-1] == '\'') {
|
||||||
|
value = value[1 : len(value)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodeFilenameParam(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findParamValueEnd finds the end position of a parameter value in a Content-Disposition header.
|
||||||
|
// It correctly handles quoted values where semicolons inside quotes should not be treated as delimiters.
|
||||||
|
// It also handles HTML entities in unquoted values (e.g., & should not be split at the semicolon).
|
||||||
|
// Returns the end index (exclusive) of the value, or -1 if it extends to the end of the string.
|
||||||
|
func findParamValueEnd(value string) int {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if len(value) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the value starts with a quote, find the matching closing quote
|
||||||
|
if value[0] == '"' || value[0] == '\'' {
|
||||||
|
quote := value[0]
|
||||||
|
// Find the closing quote, handling escaped quotes
|
||||||
|
for i := 1; i < len(value); i++ {
|
||||||
|
if value[i] == quote {
|
||||||
|
// Check if it's escaped
|
||||||
|
if i > 0 && value[i-1] == '\\' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Found closing quote, now look for ; after it
|
||||||
|
remaining := value[i+1:]
|
||||||
|
if semiIdx := strings.Index(remaining, ";"); semiIdx != -1 {
|
||||||
|
return i + 1 + semiIdx
|
||||||
|
}
|
||||||
|
return -1 // No semicolon after closing quote
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No closing quote found, treat rest of string as value
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unquoted value - find the next semicolon that's not part of an HTML entity
|
||||||
|
// HTML entities have the pattern &...; (e.g., & < > " ')
|
||||||
|
for i := 0; i < len(value); i++ {
|
||||||
|
if value[i] == ';' {
|
||||||
|
// Check if this semicolon is part of an HTML entity
|
||||||
|
// Look backwards for an & character
|
||||||
|
isEntity := false
|
||||||
|
if i > 0 {
|
||||||
|
// Look for & before this semicolon (within reasonable distance, max 10 chars)
|
||||||
|
for j := i - 1; j >= 0 && j >= i-10; j-- {
|
||||||
|
if value[j] == '&' {
|
||||||
|
// Found &, this semicolon might be part of an HTML entity
|
||||||
|
// Check if there are only alphanumeric or # between & and ;
|
||||||
|
entityChars := value[j+1 : i]
|
||||||
|
if len(entityChars) > 0 && isValidHTMLEntityChars(entityChars) {
|
||||||
|
isEntity = true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// If we hit whitespace or another special char, stop looking
|
||||||
|
if value[j] == ' ' || value[j] == '"' || value[j] == '\'' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isEntity {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1 // No semicolon, extends to end
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidHTMLEntityChars checks if a string contains only valid HTML entity characters
|
||||||
|
// (alphanumeric and #, typically for entities like & < ' etc.)
|
||||||
|
func isValidHTMLEntityChars(s string) bool {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range s {
|
||||||
|
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '#') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
+570
-15
File diff suppressed because it is too large.
Load diff
+66
-6
@@ -2,13 +2,14 @@ package base
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/GopeedLab/gopeed/pkg/util"
|
|
||||||
"github.com/mattn/go-ieproxy"
|
|
||||||
"golang.org/x/exp/slices"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/util"
|
||||||
|
"github.com/mattn/go-ieproxy"
|
||||||
|
"golang.org/x/exp/slices"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Request download request
|
// Request download request
|
||||||
@@ -77,7 +78,7 @@ func (r *Resource) Validate() error {
|
|||||||
if r.Name == "" {
|
if r.Name == "" {
|
||||||
return fmt.Errorf("invalid resource name")
|
return fmt.Errorf("invalid resource name")
|
||||||
}
|
}
|
||||||
if r.Files == nil || len(r.Files) == 0 {
|
if len(r.Files) == 0 {
|
||||||
return fmt.Errorf("invalid resource files")
|
return fmt.Errorf("invalid resource files")
|
||||||
}
|
}
|
||||||
for _, file := range r.Files {
|
for _, file := range r.Files {
|
||||||
@@ -123,7 +124,7 @@ func (o *Options) InitSelectFiles(fileSize int) {
|
|||||||
// if selectFiles is empty, select all files
|
// if selectFiles is empty, select all files
|
||||||
if len(o.SelectFiles) == 0 {
|
if len(o.SelectFiles) == 0 {
|
||||||
o.SelectFiles = make([]int, fileSize)
|
o.SelectFiles = make([]int, fileSize)
|
||||||
for i := 0; i < fileSize; i++ {
|
for i := range fileSize {
|
||||||
o.SelectFiles[i] = i
|
o.SelectFiles[i] = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,7 +149,7 @@ func ParseReqExtra[E any](req *Request) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseOptsExtra[E any](opts *Options) error {
|
func ParseOptExtra[E any](opts *Options) error {
|
||||||
if opts.Extra == nil {
|
if opts.Extra == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -182,6 +183,11 @@ type DownloaderStoreConfig struct {
|
|||||||
ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
|
ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
|
||||||
Extra map[string]any `json:"extra"`
|
Extra map[string]any `json:"extra"`
|
||||||
Proxy *DownloaderProxyConfig `json:"proxy"`
|
Proxy *DownloaderProxyConfig `json:"proxy"`
|
||||||
|
Webhook *WebhookConfig `json:"webhook"` // Webhook is the webhook configuration
|
||||||
|
Script *ScriptConfig `json:"script"` // Script is the script execution configuration
|
||||||
|
AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration
|
||||||
|
Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction configuration
|
||||||
|
AutoDeleteMissingFileTasks bool `json:"autoDeleteMissingFileTasks"` // AutoDeleteMissingFileTasks enables automatic deletion of tasks with missing files
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
|
func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
|
||||||
@@ -194,6 +200,24 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
|
|||||||
if cfg.Proxy == nil {
|
if cfg.Proxy == nil {
|
||||||
cfg.Proxy = &DownloaderProxyConfig{}
|
cfg.Proxy = &DownloaderProxyConfig{}
|
||||||
}
|
}
|
||||||
|
if cfg.Webhook == nil {
|
||||||
|
cfg.Webhook = &WebhookConfig{}
|
||||||
|
}
|
||||||
|
if cfg.Script == nil {
|
||||||
|
cfg.Script = &ScriptConfig{}
|
||||||
|
}
|
||||||
|
if cfg.AutoTorrent == nil {
|
||||||
|
cfg.AutoTorrent = &AutoTorrentConfig{
|
||||||
|
Enable: false,
|
||||||
|
DeleteAfterDownload: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.Archive == nil {
|
||||||
|
cfg.Archive = &ArchiveConfig{
|
||||||
|
AutoExtract: false,
|
||||||
|
DeleteAfterExtract: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,9 +240,45 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
|
|||||||
if cfg.Proxy == nil {
|
if cfg.Proxy == nil {
|
||||||
cfg.Proxy = beforeCfg.Proxy
|
cfg.Proxy = beforeCfg.Proxy
|
||||||
}
|
}
|
||||||
|
if cfg.Webhook == nil {
|
||||||
|
cfg.Webhook = beforeCfg.Webhook
|
||||||
|
}
|
||||||
|
if cfg.Script == nil {
|
||||||
|
cfg.Script = beforeCfg.Script
|
||||||
|
}
|
||||||
|
if cfg.AutoTorrent == nil {
|
||||||
|
cfg.AutoTorrent = beforeCfg.AutoTorrent
|
||||||
|
}
|
||||||
|
if cfg.Archive == nil {
|
||||||
|
cfg.Archive = beforeCfg.Archive
|
||||||
|
}
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebhookConfig is the webhook configuration
|
||||||
|
type WebhookConfig struct {
|
||||||
|
Enable bool `json:"enable"` // Enable is the flag to enable/disable webhooks
|
||||||
|
URLs []string `json:"urls"` // URLs is the list of webhook URLs
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScriptConfig is the script execution configuration
|
||||||
|
type ScriptConfig struct {
|
||||||
|
Enable bool `json:"enable"` // Enable is the flag to enable/disable script execution
|
||||||
|
Paths []string `json:"paths"` // Paths is the list of script paths to execute
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoTorrentConfig is the auto torrent task creation configuration
|
||||||
|
type AutoTorrentConfig struct {
|
||||||
|
Enable bool `json:"enable"` // Enable enables automatic BT task creation when downloading .torrent files
|
||||||
|
DeleteAfterDownload bool `json:"deleteAfterDownload"` // DeleteAfterDownload deletes the .torrent file after BT task creation
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveConfig is the archive extraction configuration
|
||||||
|
type ArchiveConfig struct {
|
||||||
|
AutoExtract bool `json:"autoExtract"` // AutoExtract enables automatic extraction of archives after download
|
||||||
|
DeleteAfterExtract bool `json:"deleteAfterExtract"` // DeleteAfterExtract deletes the archive after successful extraction
|
||||||
|
}
|
||||||
|
|
||||||
type DownloaderProxyConfig struct {
|
type DownloaderProxyConfig struct {
|
||||||
Enable bool `json:"enable"`
|
Enable bool `json:"enable"`
|
||||||
// System is the flag that use system proxy
|
// System is the flag that use system proxy
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
MaxRunning: 5,
|
MaxRunning: 5,
|
||||||
ProtocolConfig: map[string]any{},
|
ProtocolConfig: map[string]any{},
|
||||||
Proxy: &DownloaderProxyConfig{},
|
Proxy: &DownloaderProxyConfig{},
|
||||||
|
Webhook: &WebhookConfig{},
|
||||||
|
Script: &ScriptConfig{},
|
||||||
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
|
Enable: false,
|
||||||
|
DeleteAfterDownload: false,
|
||||||
|
},
|
||||||
|
Archive: &ArchiveConfig{
|
||||||
|
AutoExtract: false,
|
||||||
|
DeleteAfterExtract: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -29,6 +39,16 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
MaxRunning: 10,
|
MaxRunning: 10,
|
||||||
ProtocolConfig: map[string]any{},
|
ProtocolConfig: map[string]any{},
|
||||||
Proxy: &DownloaderProxyConfig{},
|
Proxy: &DownloaderProxyConfig{},
|
||||||
|
Webhook: &WebhookConfig{},
|
||||||
|
Script: &ScriptConfig{},
|
||||||
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
|
Enable: false,
|
||||||
|
DeleteAfterDownload: false,
|
||||||
|
},
|
||||||
|
Archive: &ArchiveConfig{
|
||||||
|
AutoExtract: false,
|
||||||
|
DeleteAfterExtract: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -44,6 +64,16 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
"key": "value",
|
"key": "value",
|
||||||
},
|
},
|
||||||
Proxy: &DownloaderProxyConfig{},
|
Proxy: &DownloaderProxyConfig{},
|
||||||
|
Webhook: &WebhookConfig{},
|
||||||
|
Script: &ScriptConfig{},
|
||||||
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
|
Enable: false,
|
||||||
|
DeleteAfterDownload: false,
|
||||||
|
},
|
||||||
|
Archive: &ArchiveConfig{
|
||||||
|
AutoExtract: false,
|
||||||
|
DeleteAfterExtract: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,6 +89,64 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
Proxy: &DownloaderProxyConfig{
|
Proxy: &DownloaderProxyConfig{
|
||||||
Enable: true,
|
Enable: true,
|
||||||
},
|
},
|
||||||
|
Webhook: &WebhookConfig{},
|
||||||
|
Script: &ScriptConfig{},
|
||||||
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
|
Enable: false,
|
||||||
|
DeleteAfterDownload: false,
|
||||||
|
},
|
||||||
|
Archive: &ArchiveConfig{
|
||||||
|
AutoExtract: false,
|
||||||
|
DeleteAfterExtract: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Init AutoTorrent",
|
||||||
|
&DownloaderStoreConfig{
|
||||||
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
|
Enable: true,
|
||||||
|
DeleteAfterDownload: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&DownloaderStoreConfig{
|
||||||
|
MaxRunning: 5,
|
||||||
|
ProtocolConfig: map[string]any{},
|
||||||
|
Proxy: &DownloaderProxyConfig{},
|
||||||
|
Webhook: &WebhookConfig{},
|
||||||
|
Script: &ScriptConfig{},
|
||||||
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
|
Enable: true,
|
||||||
|
DeleteAfterDownload: true,
|
||||||
|
},
|
||||||
|
Archive: &ArchiveConfig{
|
||||||
|
AutoExtract: false,
|
||||||
|
DeleteAfterExtract: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Init Archive",
|
||||||
|
&DownloaderStoreConfig{
|
||||||
|
Archive: &ArchiveConfig{
|
||||||
|
AutoExtract: true,
|
||||||
|
DeleteAfterExtract: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&DownloaderStoreConfig{
|
||||||
|
MaxRunning: 5,
|
||||||
|
ProtocolConfig: map[string]any{},
|
||||||
|
Proxy: &DownloaderProxyConfig{},
|
||||||
|
Webhook: &WebhookConfig{},
|
||||||
|
Script: &ScriptConfig{},
|
||||||
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
|
Enable: false,
|
||||||
|
DeleteAfterDownload: false,
|
||||||
|
},
|
||||||
|
Archive: &ArchiveConfig{
|
||||||
|
AutoExtract: true,
|
||||||
|
DeleteAfterExtract: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -71,6 +159,10 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
ProtocolConfig: tt.fields.ProtocolConfig,
|
ProtocolConfig: tt.fields.ProtocolConfig,
|
||||||
Extra: tt.fields.Extra,
|
Extra: tt.fields.Extra,
|
||||||
Proxy: tt.fields.Proxy,
|
Proxy: tt.fields.Proxy,
|
||||||
|
Webhook: tt.fields.Webhook,
|
||||||
|
Script: tt.fields.Script,
|
||||||
|
AutoTorrent: tt.fields.AutoTorrent,
|
||||||
|
Archive: tt.fields.Archive,
|
||||||
}
|
}
|
||||||
if got := cfg.Init(); !reflect.DeepEqual(got, tt.want) {
|
if got := cfg.Init(); !reflect.DeepEqual(got, tt.want) {
|
||||||
t.Errorf("Init() = %v, want %v", got, tt.want)
|
t.Errorf("Init() = %v, want %v", got, tt.want)
|
||||||
@@ -245,6 +337,84 @@ func TestDownloaderStoreConfig_Merge(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"Merge AutoTorrent No Override",
|
||||||
|
&DownloaderStoreConfig{
|
||||||
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
|
Enable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args{
|
||||||
|
beforeCfg: &DownloaderStoreConfig{
|
||||||
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
|
Enable: false,
|
||||||
|
DeleteAfterDownload: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&DownloaderStoreConfig{
|
||||||
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
|
Enable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Merge AutoTorrent Override",
|
||||||
|
&DownloaderStoreConfig{},
|
||||||
|
args{
|
||||||
|
beforeCfg: &DownloaderStoreConfig{
|
||||||
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
|
Enable: true,
|
||||||
|
DeleteAfterDownload: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&DownloaderStoreConfig{
|
||||||
|
AutoTorrent: &AutoTorrentConfig{
|
||||||
|
Enable: true,
|
||||||
|
DeleteAfterDownload: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Merge Archive No Override",
|
||||||
|
&DownloaderStoreConfig{
|
||||||
|
Archive: &ArchiveConfig{
|
||||||
|
AutoExtract: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
args{
|
||||||
|
beforeCfg: &DownloaderStoreConfig{
|
||||||
|
Archive: &ArchiveConfig{
|
||||||
|
AutoExtract: false,
|
||||||
|
DeleteAfterExtract: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&DownloaderStoreConfig{
|
||||||
|
Archive: &ArchiveConfig{
|
||||||
|
AutoExtract: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Merge Archive Override",
|
||||||
|
&DownloaderStoreConfig{},
|
||||||
|
args{
|
||||||
|
beforeCfg: &DownloaderStoreConfig{
|
||||||
|
Archive: &ArchiveConfig{
|
||||||
|
AutoExtract: false,
|
||||||
|
DeleteAfterExtract: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&DownloaderStoreConfig{
|
||||||
|
Archive: &ArchiveConfig{
|
||||||
|
AutoExtract: false,
|
||||||
|
DeleteAfterExtract: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -255,6 +425,9 @@ func TestDownloaderStoreConfig_Merge(t *testing.T) {
|
|||||||
ProtocolConfig: tt.fields.ProtocolConfig,
|
ProtocolConfig: tt.fields.ProtocolConfig,
|
||||||
Extra: tt.fields.Extra,
|
Extra: tt.fields.Extra,
|
||||||
Proxy: tt.fields.Proxy,
|
Proxy: tt.fields.Proxy,
|
||||||
|
Webhook: tt.fields.Webhook,
|
||||||
|
AutoTorrent: tt.fields.AutoTorrent,
|
||||||
|
Archive: tt.fields.Archive,
|
||||||
}
|
}
|
||||||
if got := cfg.Merge(tt.args.beforeCfg); !reflect.DeepEqual(got, tt.want) {
|
if got := cfg.Merge(tt.args.beforeCfg); !reflect.DeepEqual(got, tt.want) {
|
||||||
t.Errorf("Merge() = %v, want %v", got, tt.want)
|
t.Errorf("Merge() = %v, want %v", got, tt.want)
|
||||||
|
|||||||
+651
-58
File diff suppressed because it is too large.
Load diff
+2146
-57
File diff suppressed because it is too large.
Load diff
+6
-6
@@ -217,7 +217,7 @@
|
|||||||
"@webassemblyjs/helper-wasm-bytecode": "1.11.1",
|
"@webassemblyjs/helper-wasm-bytecode": "1.11.1",
|
||||||
"@webassemblyjs/helper-wasm-section": "1.11.1",
|
"@webassemblyjs/helper-wasm-section": "1.11.1",
|
||||||
"@webassemblyjs/wasm-gen": "1.11.1",
|
"@webassemblyjs/wasm-gen": "1.11.1",
|
||||||
"@webassemblyjs/wasm-opt": "1.11.1",
|
"@webassemblyjs/wasm-opts": "1.11.1",
|
||||||
"@webassemblyjs/wasm-parser": "1.11.1",
|
"@webassemblyjs/wasm-parser": "1.11.1",
|
||||||
"@webassemblyjs/wast-printer": "1.11.1"
|
"@webassemblyjs/wast-printer": "1.11.1"
|
||||||
}
|
}
|
||||||
@@ -235,9 +235,9 @@
|
|||||||
"@webassemblyjs/utf8": "1.11.1"
|
"@webassemblyjs/utf8": "1.11.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@webassemblyjs/wasm-opt": {
|
"node_modules/@webassemblyjs/wasm-opts": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-opts/-/wasm-opts-1.11.1.tgz",
|
||||||
"integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==",
|
"integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2114,7 +2114,7 @@
|
|||||||
"@webassemblyjs/helper-wasm-bytecode": "1.11.1",
|
"@webassemblyjs/helper-wasm-bytecode": "1.11.1",
|
||||||
"@webassemblyjs/helper-wasm-section": "1.11.1",
|
"@webassemblyjs/helper-wasm-section": "1.11.1",
|
||||||
"@webassemblyjs/wasm-gen": "1.11.1",
|
"@webassemblyjs/wasm-gen": "1.11.1",
|
||||||
"@webassemblyjs/wasm-opt": "1.11.1",
|
"@webassemblyjs/wasm-opts": "1.11.1",
|
||||||
"@webassemblyjs/wasm-parser": "1.11.1",
|
"@webassemblyjs/wasm-parser": "1.11.1",
|
||||||
"@webassemblyjs/wast-printer": "1.11.1"
|
"@webassemblyjs/wast-printer": "1.11.1"
|
||||||
}
|
}
|
||||||
@@ -2132,9 +2132,9 @@
|
|||||||
"@webassemblyjs/utf8": "1.11.1"
|
"@webassemblyjs/utf8": "1.11.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@webassemblyjs/wasm-opt": {
|
"@webassemblyjs/wasm-opts": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-opts/-/wasm-opts-1.11.1.tgz",
|
||||||
"integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==",
|
"integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
|
|||||||
+16
-12
@@ -3,6 +3,14 @@ package download
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/internal/logger"
|
"github.com/GopeedLab/gopeed/internal/logger"
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
"github.com/GopeedLab/gopeed/pkg/download/engine"
|
||||||
@@ -12,13 +20,6 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
"github.com/go-git/go-git/v5"
|
"github.com/go-git/go-git/v5"
|
||||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -200,10 +201,14 @@ func (d *Downloader) fetchExtensionByGit(url string, handler func(tempExtPath st
|
|||||||
subPath = pathArr[1]
|
subPath = pathArr[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
tempExtDir := filepath.Join(d.cfg.StorageDir, tempExtensionsDir, projectPath)
|
// Use unique suffix to avoid concurrent conflicts when multiple git operations
|
||||||
if err := util.RmAndMkDirAll(tempExtDir); err != nil {
|
// target the same extension (e.g., install and update check happening simultaneously)
|
||||||
|
tempExtDir := filepath.Join(d.cfg.StorageDir, tempExtensionsDir, fmt.Sprintf("%s_%d", projectPath, time.Now().UnixNano()))
|
||||||
|
if err := os.MkdirAll(tempExtDir, 0755); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer os.RemoveAll(tempExtDir)
|
||||||
|
|
||||||
proxyOptions := transport.ProxyOptions{}
|
proxyOptions := transport.ProxyOptions{}
|
||||||
proxyUrl := d.cfg.DownloaderStoreConfig.Proxy.ToUrl()
|
proxyUrl := d.cfg.DownloaderStoreConfig.Proxy.ToUrl()
|
||||||
if proxyUrl != nil {
|
if proxyUrl != nil {
|
||||||
@@ -220,7 +225,6 @@ func (d *Downloader) fetchExtensionByGit(url string, handler func(tempExtPath st
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(tempExtDir)
|
|
||||||
|
|
||||||
return handler(filepath.Join(tempExtDir, subPath), false)
|
return handler(filepath.Join(tempExtDir, subPath), false)
|
||||||
}
|
}
|
||||||
@@ -260,9 +264,9 @@ func (d *Downloader) triggerOnResolve(req *base.Request) (res *base.Resource, er
|
|||||||
gopeed.Logger.logger.Warn().Err(err).Msgf("[%s] resource invalid", ext.buildIdentity())
|
gopeed.Logger.logger.Warn().Err(err).Msgf("[%s] resource invalid", ext.buildIdentity())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.Res.Name = util.ReplaceInvalidFilename(ctx.Res.Name)
|
ctx.Res.Name = util.SafeFilename(ctx.Res.Name)
|
||||||
for _, file := range ctx.Res.Files {
|
for _, file := range ctx.Res.Files {
|
||||||
file.Name = util.ReplaceInvalidFilename(file.Name)
|
file.Name = util.SafeFilename(file.Name)
|
||||||
}
|
}
|
||||||
ctx.Res.CalcSize(nil)
|
ctx.Res.CalcSize(nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ package download
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/internal/logger"
|
"github.com/GopeedLab/gopeed/internal/logger"
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
|
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDownloader_InstallExtensionByFolder(t *testing.T) {
|
func TestDownloader_InstallExtensionByFolder(t *testing.T) {
|
||||||
@@ -18,7 +19,7 @@ func TestDownloader_InstallExtensionByFolder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
rr, err := downloader.Resolve(&base.Request{
|
rr, err := downloader.Resolve(&base.Request{
|
||||||
URL: "https://github.com/test",
|
URL: "https://github.com/test",
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -35,7 +36,7 @@ func TestDownloader_InstallExtensionByFolderDevMode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
rr, err := downloader.Resolve(&base.Request{
|
rr, err := downloader.Resolve(&base.Request{
|
||||||
URL: "https://github.com/test",
|
URL: "https://github.com/test",
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -52,7 +53,7 @@ func TestDownloader_InstallExtensionByGit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
rr, err := downloader.Resolve(&base.Request{
|
rr, err := downloader.Resolve(&base.Request{
|
||||||
URL: "https://github.com/GopeedLab/gopeed/releases",
|
URL: "https://github.com/GopeedLab/gopeed/releases",
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -69,7 +70,7 @@ func TestDownloader_InstallExtensionByGitSimple(t *testing.T) {
|
|||||||
}
|
}
|
||||||
rr, err := downloader.Resolve(&base.Request{
|
rr, err := downloader.Resolve(&base.Request{
|
||||||
URL: "https://github.com/GopeedLab/gopeed/releases",
|
URL: "https://github.com/GopeedLab/gopeed/releases",
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -86,7 +87,7 @@ func TestDownloader_InstallExtensionByGitFull(t *testing.T) {
|
|||||||
}
|
}
|
||||||
rr, err := downloader.Resolve(&base.Request{
|
rr, err := downloader.Resolve(&base.Request{
|
||||||
URL: "https://github.com/GopeedLab/gopeed/releases",
|
URL: "https://github.com/GopeedLab/gopeed/releases",
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -156,7 +157,7 @@ func TestDownloader_UpgradeExtension(t *testing.T) {
|
|||||||
|
|
||||||
rr, err := downloader.Resolve(&base.Request{
|
rr, err := downloader.Resolve(&base.Request{
|
||||||
URL: "https://test.com",
|
URL: "https://test.com",
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -185,7 +186,7 @@ func TestDownloader_Extension_OnStart(t *testing.T) {
|
|||||||
select {
|
select {
|
||||||
case err = <-errCh:
|
case err = <-errCh:
|
||||||
break
|
break
|
||||||
case <-time.After(time.Second * 10):
|
case <-time.After(time.Second * 30): // Increased timeout for real network requests
|
||||||
err = errors.New("timeout")
|
err = errors.New("timeout")
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -238,7 +239,7 @@ func TestDownloader_Extension_OnError(t *testing.T) {
|
|||||||
select {
|
select {
|
||||||
case err = <-errCh:
|
case err = <-errCh:
|
||||||
break
|
break
|
||||||
case <-time.After(time.Second * 10):
|
case <-time.After(time.Second * 30): // Increased timeout for real network requests
|
||||||
err = errors.New("timeout")
|
err = errors.New("timeout")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +274,7 @@ func TestDownloader_Extension_OnDone(t *testing.T) {
|
|||||||
select {
|
select {
|
||||||
case err = <-errCh:
|
case err = <-errCh:
|
||||||
break
|
break
|
||||||
case <-time.After(time.Second * 10):
|
case <-time.After(time.Second * 30): // Increased timeout for real network requests
|
||||||
err = errors.New("timeout")
|
err = errors.New("timeout")
|
||||||
}
|
}
|
||||||
// wait for script execution
|
// wait for script execution
|
||||||
@@ -300,7 +301,7 @@ func TestDownloader_Extension_Errors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
rr, err := downloader.Resolve(&base.Request{
|
rr, err := downloader.Resolve(&base.Request{
|
||||||
URL: "https://github.com/test",
|
URL: "https://github.com/test",
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -315,7 +316,7 @@ func TestDownloader_Extension_Errors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
rr, err := downloader.Resolve(&base.Request{
|
rr, err := downloader.Resolve(&base.Request{
|
||||||
URL: "https://github.com/test",
|
URL: "https://github.com/test",
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -330,7 +331,7 @@ func TestDownloader_Extension_Errors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_, err := downloader.Resolve(&base.Request{
|
_, err := downloader.Resolve(&base.Request{
|
||||||
URL: "https://github.com/test",
|
URL: "https://github.com/test",
|
||||||
})
|
}, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("except error, but got nil")
|
t.Fatalf("except error, but got nil")
|
||||||
}
|
}
|
||||||
@@ -352,7 +353,7 @@ func TestDownloader_Extension_Settings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
rr, err := downloader.Resolve(&base.Request{
|
rr, err := downloader.Resolve(&base.Request{
|
||||||
URL: "https://github.com/test",
|
URL: "https://github.com/test",
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -373,7 +374,7 @@ func TestDownloader_Extension_Settings(t *testing.T) {
|
|||||||
})
|
})
|
||||||
rr, err := downloader.Resolve(&base.Request{
|
rr, err := downloader.Resolve(&base.Request{
|
||||||
URL: "https://github.com/test",
|
URL: "https://github.com/test",
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -390,7 +391,7 @@ func TestDownloader_ExtensionStorage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
rr, err := downloader.Resolve(&base.Request{
|
rr, err := downloader.Resolve(&base.Request{
|
||||||
URL: "https://github.com/test",
|
URL: "https://github.com/test",
|
||||||
})
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/mholt/archives"
|
||||||
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
|
)
|
||||||
|
|
||||||
|
// supportedArchiveExtensions contains file extensions supported by mholt/archives library
|
||||||
|
var supportedArchiveExtensions = []string{
|
||||||
|
// Archive formats
|
||||||
|
".zip",
|
||||||
|
".tar",
|
||||||
|
".tar.gz", ".tgz",
|
||||||
|
".tar.bz2", ".tbz2",
|
||||||
|
".tar.xz", ".txz",
|
||||||
|
".tar.lz4", ".tlz4",
|
||||||
|
".tar.sz", ".tsz",
|
||||||
|
".tar.zst", ".tzst",
|
||||||
|
".rar",
|
||||||
|
".7z",
|
||||||
|
// Compression formats
|
||||||
|
".gz",
|
||||||
|
".bz2",
|
||||||
|
".xz",
|
||||||
|
".lz4",
|
||||||
|
".sz",
|
||||||
|
".zst",
|
||||||
|
".br",
|
||||||
|
".lz",
|
||||||
|
}
|
||||||
|
|
||||||
|
// multiPartArchivePatterns contains regex patterns for multi-part archive detection
|
||||||
|
// Each pattern should have a capture group for the base name and part number
|
||||||
|
var multiPartArchivePatterns = []*regexp.Regexp{
|
||||||
|
// 7z multi-part: file.7z.001, file.7z.002, etc.
|
||||||
|
regexp.MustCompile(`(?i)^(.+\.7z)\.(\d{3})$`),
|
||||||
|
// RAR multi-part (new style): file.part01.rar, file.part02.rar, etc.
|
||||||
|
regexp.MustCompile(`(?i)^(.+)\.part(\d+)\.rar$`),
|
||||||
|
// RAR multi-part (old style): file.rar, file.r00, file.r01, etc. (first file is .rar)
|
||||||
|
regexp.MustCompile(`(?i)^(.+)\.r(\d{2})$`),
|
||||||
|
// ZIP multi-part: file.zip.001, file.zip.002, etc.
|
||||||
|
regexp.MustCompile(`(?i)^(.+\.zip)\.(\d{3})$`),
|
||||||
|
// ZIP split: file.z01, file.z02, ... file.zip (last file is .zip)
|
||||||
|
regexp.MustCompile(`(?i)^(.+)\.z(\d{2})$`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchivePartInfo contains information about a multi-part archive
|
||||||
|
type ArchivePartInfo struct {
|
||||||
|
// IsMultiPart indicates if this file is part of a multi-part archive
|
||||||
|
IsMultiPart bool
|
||||||
|
// BaseName is the common base name for all parts (without part number extension)
|
||||||
|
BaseName string
|
||||||
|
// PartNumber is the part number (1-indexed)
|
||||||
|
PartNumber int
|
||||||
|
// FirstPartPath is the path to the first part of the archive
|
||||||
|
FirstPartPath string
|
||||||
|
// Pattern indicates which pattern matched (for determining extraction method)
|
||||||
|
Pattern string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractProgressCallback is called to report extraction progress
|
||||||
|
type ExtractProgressCallback func(extractedFiles int, totalFiles int, progress int)
|
||||||
|
|
||||||
|
// newZipFormat creates a Zip format with proper character encoding support.
|
||||||
|
// It uses GB18030 encoding to handle Chinese characters in filenames that may
|
||||||
|
// be encoded with legacy GBK/GB18030 instead of UTF-8.
|
||||||
|
func newZipFormat() archives.Zip {
|
||||||
|
return archives.Zip{
|
||||||
|
// GB18030 is a superset of GBK and handles Chinese characters correctly
|
||||||
|
TextEncoding: simplifiedchinese.GB18030,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isArchiveFile checks if a file is a supported archive format
|
||||||
|
func isArchiveFile(filename string) bool {
|
||||||
|
lowerName := strings.ToLower(filename)
|
||||||
|
// Check for multi-part archive first
|
||||||
|
if isMultiPartArchive(filename) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return slices.ContainsFunc(supportedArchiveExtensions, func(ext string) bool {
|
||||||
|
return strings.HasSuffix(lowerName, ext)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// archiveInfo holds information about an opened archive
|
||||||
|
type archiveInfo struct {
|
||||||
|
file *os.File
|
||||||
|
stat os.FileInfo
|
||||||
|
format archives.Format
|
||||||
|
input io.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// openArchive opens an archive file and identifies its format
|
||||||
|
func openArchive(archivePath string, password string) (*archiveInfo, error) {
|
||||||
|
file, err := os.Open(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stat, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
file.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
format, input, err := archives.Identify(context.Background(), archivePath, file)
|
||||||
|
if err != nil {
|
||||||
|
file.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure format-specific settings
|
||||||
|
// Handle password-protected archives and character encoding
|
||||||
|
if password != "" {
|
||||||
|
if rar, ok := format.(archives.Rar); ok {
|
||||||
|
rar.Password = password
|
||||||
|
format = rar
|
||||||
|
}
|
||||||
|
if sz, ok := format.(archives.SevenZip); ok {
|
||||||
|
sz.Password = password
|
||||||
|
format = sz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For ZIP files, configure character encoding to handle non-UTF8 filenames
|
||||||
|
// This is essential for Chinese characters encoded in GBK/GB18030
|
||||||
|
if _, ok := format.(archives.Zip); ok {
|
||||||
|
format = newZipFormat()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &archiveInfo{
|
||||||
|
file: file,
|
||||||
|
stat: stat,
|
||||||
|
format: format,
|
||||||
|
input: input,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createExtractionHandler creates a handler function for extracting files with progress tracking
|
||||||
|
func createExtractionHandler(destDir string, totalFiles int, progressCallback ExtractProgressCallback) func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||||
|
var extractedFiles atomic.Int32
|
||||||
|
return func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||||
|
err := extractFile(ctx, fileInfo, destDir)
|
||||||
|
if err == nil && !fileInfo.IsDir() {
|
||||||
|
extracted := int(extractedFiles.Add(1))
|
||||||
|
if progressCallback != nil && totalFiles > 0 {
|
||||||
|
progress := int(math.Min(float64((extracted*100)/totalFiles), 100))
|
||||||
|
progressCallback(extracted, totalFiles, progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractArchive extracts an archive file to a destination directory
|
||||||
|
func extractArchive(archivePath string, destDir string, password string, progressCallback ExtractProgressCallback) error {
|
||||||
|
// Open the archive file
|
||||||
|
info, err := openArchive(archivePath, password)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer info.file.Close()
|
||||||
|
|
||||||
|
// Create destination directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle extraction based on format type
|
||||||
|
switch f := info.format.(type) {
|
||||||
|
case archives.Extractor:
|
||||||
|
// For archive formats (zip, rar, 7z, tar, etc.)
|
||||||
|
// First, count total files for progress tracking
|
||||||
|
totalFiles, err := countArchiveFiles(archivePath, password)
|
||||||
|
if err != nil {
|
||||||
|
// If counting fails, proceed without progress reporting
|
||||||
|
totalFiles = 0
|
||||||
|
}
|
||||||
|
return f.Extract(context.Background(), info.input, createExtractionHandler(destDir, totalFiles, progressCallback))
|
||||||
|
case archives.Decompressor:
|
||||||
|
// For single-file compression formats (gz, bz2, xz, etc.)
|
||||||
|
// Decompress to a file without the compression extension
|
||||||
|
baseName := filepath.Base(archivePath)
|
||||||
|
lowerBaseName := strings.ToLower(baseName)
|
||||||
|
for _, ext := range supportedArchiveExtensions {
|
||||||
|
if strings.HasSuffix(lowerBaseName, ext) {
|
||||||
|
// Get the actual suffix from the original filename (preserving case)
|
||||||
|
actualSuffix := baseName[len(baseName)-len(ext):]
|
||||||
|
baseName = strings.TrimSuffix(baseName, actualSuffix)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
destPath := filepath.Join(destDir, baseName)
|
||||||
|
|
||||||
|
reader, err := f.OpenReader(info.input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
destFile, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer destFile.Close()
|
||||||
|
|
||||||
|
// Report progress at start and end for decompression
|
||||||
|
if progressCallback != nil {
|
||||||
|
progressCallback(0, 1, 0)
|
||||||
|
}
|
||||||
|
_, err = io.Copy(destFile, reader)
|
||||||
|
if err == nil && progressCallback != nil {
|
||||||
|
progressCallback(1, 1, 100)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
case archives.Archiver:
|
||||||
|
// This format is an archiver, try to extract using the extractor interface
|
||||||
|
if ext, ok := info.format.(archives.Extractor); ok {
|
||||||
|
// Reset file position
|
||||||
|
if seeker, ok := info.input.(io.Seeker); ok {
|
||||||
|
seeker.Seek(0, io.SeekStart)
|
||||||
|
}
|
||||||
|
// Count total files for progress tracking
|
||||||
|
totalFiles, err := countArchiveFiles(archivePath, password)
|
||||||
|
if err != nil {
|
||||||
|
totalFiles = 0
|
||||||
|
}
|
||||||
|
return ext.Extract(context.Background(), io.NewSectionReader(info.file, 0, info.stat.Size()), createExtractionHandler(destDir, totalFiles, progressCallback))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createCountingHandler creates a handler function for counting files in an archive
|
||||||
|
func createCountingHandler(count *int) func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||||
|
return func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||||
|
if !fileInfo.IsDir() {
|
||||||
|
(*count)++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countArchiveFiles counts the number of files in an archive for progress calculation
|
||||||
|
func countArchiveFiles(archivePath string, password string) (int, error) {
|
||||||
|
info, err := openArchive(archivePath, password)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer info.file.Close()
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
switch f := info.format.(type) {
|
||||||
|
case archives.Extractor:
|
||||||
|
err = f.Extract(context.Background(), info.input, createCountingHandler(&count))
|
||||||
|
case archives.Archiver:
|
||||||
|
if ext, ok := info.format.(archives.Extractor); ok {
|
||||||
|
if seeker, ok := info.input.(io.Seeker); ok {
|
||||||
|
seeker.Seek(0, io.SeekStart)
|
||||||
|
}
|
||||||
|
err = ext.Extract(context.Background(), io.NewSectionReader(info.file, 0, info.stat.Size()), createCountingHandler(&count))
|
||||||
|
}
|
||||||
|
case archives.Decompressor:
|
||||||
|
// Single file compression, count as 1
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFile handles extracting a single file from an archive
|
||||||
|
func extractFile(ctx context.Context, fileInfo archives.FileInfo, destDir string) error {
|
||||||
|
// Skip directories, they will be created when extracting files
|
||||||
|
if fileInfo.IsDir() {
|
||||||
|
destPath := filepath.Join(destDir, fileInfo.NameInArchive)
|
||||||
|
return os.MkdirAll(destPath, fileInfo.Mode())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize the path to prevent path traversal attacks
|
||||||
|
cleanPath := filepath.Clean(fileInfo.NameInArchive)
|
||||||
|
if strings.HasPrefix(cleanPath, "..") || filepath.IsAbs(cleanPath) {
|
||||||
|
// Skip files with suspicious paths
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
destPath := filepath.Join(destDir, cleanPath)
|
||||||
|
|
||||||
|
// Create parent directories
|
||||||
|
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the file from the archive
|
||||||
|
reader, err := fileInfo.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
// Create the destination file
|
||||||
|
destFile, err := os.Create(destPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer destFile.Close()
|
||||||
|
|
||||||
|
// Copy the contents
|
||||||
|
_, err = io.Copy(destFile, reader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set file permissions
|
||||||
|
return os.Chmod(destPath, fileInfo.Mode())
|
||||||
|
}
|
||||||
|
|
||||||
|
// isMultiPartArchive checks if a file is part of a multi-part archive
|
||||||
|
func isMultiPartArchive(filename string) bool {
|
||||||
|
info := getArchivePartInfo(filename)
|
||||||
|
return info.IsMultiPart
|
||||||
|
}
|
||||||
|
|
||||||
|
// getArchivePartInfo returns detailed information about a multi-part archive file
|
||||||
|
func getArchivePartInfo(filename string) ArchivePartInfo {
|
||||||
|
baseName := filepath.Base(filename)
|
||||||
|
|
||||||
|
for _, pattern := range multiPartArchivePatterns {
|
||||||
|
matches := pattern.FindStringSubmatch(baseName)
|
||||||
|
if len(matches) >= 3 {
|
||||||
|
partNum := 0
|
||||||
|
_, err := parsePartNumber(matches[2], &partNum)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
info := ArchivePartInfo{
|
||||||
|
IsMultiPart: true,
|
||||||
|
BaseName: matches[1],
|
||||||
|
PartNumber: partNum,
|
||||||
|
Pattern: pattern.String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the first part path based on pattern
|
||||||
|
dir := filepath.Dir(filename)
|
||||||
|
info.FirstPartPath = determineFirstPartPath(dir, info.BaseName, pattern.String())
|
||||||
|
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for .rar files that might be the first part of old-style multi-part RAR
|
||||||
|
if strings.HasSuffix(strings.ToLower(baseName), ".rar") && !strings.Contains(strings.ToLower(baseName), ".part") {
|
||||||
|
// Check if there are .r00, .r01 files in the same directory
|
||||||
|
dir := filepath.Dir(filename)
|
||||||
|
nameWithoutExt := strings.TrimSuffix(baseName, filepath.Ext(baseName))
|
||||||
|
r00Path := filepath.Join(dir, nameWithoutExt+".r00")
|
||||||
|
if _, err := os.Stat(r00Path); err == nil {
|
||||||
|
return ArchivePartInfo{
|
||||||
|
IsMultiPart: true,
|
||||||
|
BaseName: nameWithoutExt,
|
||||||
|
PartNumber: 1, // .rar is the first part in old-style
|
||||||
|
FirstPartPath: filename,
|
||||||
|
Pattern: "rar-old-style",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ArchivePartInfo{IsMultiPart: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePartNumber parses a part number string and stores it in the provided pointer
|
||||||
|
// Returns the parsed number and nil error on success
|
||||||
|
func parsePartNumber(s string, partNum *int) (int, error) {
|
||||||
|
n := 0
|
||||||
|
for _, c := range s {
|
||||||
|
if c >= '0' && c <= '9' {
|
||||||
|
n = n*10 + int(c-'0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For .001, .002 style, part number is the value itself
|
||||||
|
// For .part01 style, part number is the value itself
|
||||||
|
// For .r00, .r01 style: r00=2 (since .rar is part 1), r01=3, etc.
|
||||||
|
// However, for consistency, we return the raw number and handle the offset elsewhere
|
||||||
|
*partNum = n
|
||||||
|
// Treat 00 as part 0, let callers handle the semantics
|
||||||
|
if n == 0 {
|
||||||
|
*partNum = 1 // For .001 format, 001 should be 1
|
||||||
|
}
|
||||||
|
return *partNum, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// determineFirstPartPath determines the path to the first part of a multi-part archive
|
||||||
|
func determineFirstPartPath(dir, baseName, pattern string) string {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(pattern, `.7z)`):
|
||||||
|
// 7z multi-part: first part is .7z.001
|
||||||
|
return filepath.Join(dir, baseName+".001")
|
||||||
|
case strings.Contains(pattern, `.part`):
|
||||||
|
// RAR new style: first part is .part01.rar or .part1.rar
|
||||||
|
// Try both single and double digit formats
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, baseName+".part1.rar")); err == nil {
|
||||||
|
return filepath.Join(dir, baseName+".part1.rar")
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, baseName+".part01.rar")
|
||||||
|
case strings.Contains(pattern, `.r(`):
|
||||||
|
// RAR old style: first part is .rar (not .r00)
|
||||||
|
return filepath.Join(dir, baseName+".rar")
|
||||||
|
case strings.Contains(pattern, `.zip)`):
|
||||||
|
// ZIP multi-part: first part is .zip.001
|
||||||
|
return filepath.Join(dir, baseName+".001")
|
||||||
|
case strings.Contains(pattern, `.z(`):
|
||||||
|
// ZIP split: last part is .zip, but extraction should start from .z01
|
||||||
|
return filepath.Join(dir, baseName+".z01")
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isFirstPart checks if the given file is the first part of a multi-part archive
|
||||||
|
func isFirstPart(filename string) bool {
|
||||||
|
info := getArchivePartInfo(filename)
|
||||||
|
if !info.IsMultiPart {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// For most formats, part 1 is the first part
|
||||||
|
// For old-style RAR, check if this is the .rar file
|
||||||
|
if info.Pattern == "rar-old-style" {
|
||||||
|
return strings.HasSuffix(strings.ToLower(filename), ".rar")
|
||||||
|
}
|
||||||
|
|
||||||
|
return info.PartNumber == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMultiPartArchiveBaseName returns the base name for a multi-part archive
|
||||||
|
// This is used to group related parts together
|
||||||
|
func GetMultiPartArchiveBaseName(filename string) string {
|
||||||
|
info := getArchivePartInfo(filename)
|
||||||
|
if !info.IsMultiPart {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(filepath.Dir(filename), info.BaseName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractMultiPartArchive extracts a multi-part archive starting from the first part
|
||||||
|
func extractMultiPartArchive(firstPartPath string, destDir string, password string, progressCallback ExtractProgressCallback) error {
|
||||||
|
info := getArchivePartInfo(firstPartPath)
|
||||||
|
|
||||||
|
// For 7z multi-part archives, the bodgit/sevenzip library handles multi-volume automatically
|
||||||
|
// when using OpenReader with a .001 file
|
||||||
|
if strings.Contains(info.Pattern, `\.7z\)`) || strings.HasSuffix(strings.ToLower(firstPartPath), ".7z.001") {
|
||||||
|
return extractSevenZipMultiPart(firstPartPath, destDir, password, progressCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For RAR multi-part archives, use the archives library with Name and FS fields
|
||||||
|
if strings.Contains(info.Pattern, "rar") || info.Pattern == "rar-old-style" {
|
||||||
|
return extractRarMultiPart(firstPartPath, destDir, password, progressCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For ZIP multi-part archives (.zip.001, .zip.002, etc.), concatenate parts and extract
|
||||||
|
if strings.Contains(info.Pattern, `\.zip\)`) || strings.HasSuffix(strings.ToLower(firstPartPath), ".zip.001") {
|
||||||
|
return extractZipMultiPart(firstPartPath, destDir, password, progressCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other formats, try standard extraction (may not work for all multi-part formats)
|
||||||
|
return extractArchive(firstPartPath, destDir, password, progressCallback)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/bodgit/sevenzip"
|
||||||
|
)
|
||||||
|
|
||||||
|
// extractSevenZipMultiPart extracts a multi-part 7z archive using bodgit/sevenzip directly
|
||||||
|
// The mholt/archives wrapper doesn't properly handle multi-part 7z files because it uses
|
||||||
|
// io.SectionReader which can only see the first part. The bodgit/sevenzip library's
|
||||||
|
// OpenReaderWithPassword function handles multi-part files automatically when given the .001 file path.
|
||||||
|
func extractSevenZipMultiPart(firstPartPath string, destDir string, password string, progressCallback ExtractProgressCallback) error {
|
||||||
|
// Use bodgit/sevenzip directly - it automatically handles .001, .002, etc. files
|
||||||
|
var reader *sevenzip.ReadCloser
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if password != "" {
|
||||||
|
reader, err = sevenzip.OpenReaderWithPassword(firstPartPath, password)
|
||||||
|
} else {
|
||||||
|
reader, err = sevenzip.OpenReader(firstPartPath)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
// Create destination directory
|
||||||
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total files for progress
|
||||||
|
totalFiles := 0
|
||||||
|
for _, f := range reader.File {
|
||||||
|
if !f.FileInfo().IsDir() {
|
||||||
|
totalFiles++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract files with progress tracking
|
||||||
|
extractedFiles := 0
|
||||||
|
for _, f := range reader.File {
|
||||||
|
destPath := filepath.Join(destDir, f.Name)
|
||||||
|
|
||||||
|
if f.FileInfo().IsDir() {
|
||||||
|
if err := os.MkdirAll(destPath, f.Mode()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract file - open, copy, close immediately (as recommended by bodgit/sevenzip)
|
||||||
|
if err := extractSevenZipFile(f, destPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
extractedFiles++
|
||||||
|
if progressCallback != nil && totalFiles > 0 {
|
||||||
|
progress := int(float64(extractedFiles) / float64(totalFiles) * 100)
|
||||||
|
progressCallback(extractedFiles, totalFiles, progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractSevenZipFile extracts a single file from a 7z archive
|
||||||
|
// This follows the bodgit/sevenzip recommended pattern of closing rc before processing the next file
|
||||||
|
func extractSevenZipFile(f *sevenzip.File, destPath string) error {
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer outFile.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(outFile, rc)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExtractionJob represents a single extraction job in the queue
|
||||||
|
type ExtractionJob struct {
|
||||||
|
// ID is a unique identifier for this job (usually task ID or multi-part base name)
|
||||||
|
ID string
|
||||||
|
// Execute is the function that performs the actual extraction
|
||||||
|
Execute func()
|
||||||
|
// done channel is signaled when the job has been executed
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExtractionJob creates a new extraction job
|
||||||
|
func NewExtractionJob(id string, execute func()) *ExtractionJob {
|
||||||
|
return &ExtractionJob{
|
||||||
|
ID: id,
|
||||||
|
Execute: execute,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait blocks until the job has been executed
|
||||||
|
func (j *ExtractionJob) Wait() {
|
||||||
|
<-j.done
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractionQueue manages a queue of extraction jobs to prevent resource exhaustion
|
||||||
|
// by ensuring only one extraction (or one multi-part archive extraction) runs at a time
|
||||||
|
type ExtractionQueue struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cond *sync.Cond
|
||||||
|
jobs []*ExtractionJob
|
||||||
|
running bool
|
||||||
|
shutdown bool
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExtractionQueue creates a new extraction queue
|
||||||
|
func NewExtractionQueue() *ExtractionQueue {
|
||||||
|
q := &ExtractionQueue{
|
||||||
|
jobs: make([]*ExtractionJob, 0),
|
||||||
|
}
|
||||||
|
q.cond = sync.NewCond(&q.mu)
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the queue worker that processes jobs sequentially
|
||||||
|
func (q *ExtractionQueue) Start() {
|
||||||
|
q.mu.Lock()
|
||||||
|
if q.running {
|
||||||
|
q.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q.running = true
|
||||||
|
q.shutdown = false
|
||||||
|
q.mu.Unlock()
|
||||||
|
|
||||||
|
q.wg.Add(1)
|
||||||
|
go q.worker()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the queue worker gracefully
|
||||||
|
// It waits for the current job to complete but discards pending jobs
|
||||||
|
func (q *ExtractionQueue) Stop() {
|
||||||
|
q.mu.Lock()
|
||||||
|
if !q.running {
|
||||||
|
q.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q.shutdown = true
|
||||||
|
q.cond.Signal()
|
||||||
|
q.mu.Unlock()
|
||||||
|
|
||||||
|
q.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue adds a new extraction job to the queue
|
||||||
|
// The job will be executed when its turn comes (FIFO order)
|
||||||
|
// Returns the job so the caller can wait for completion if needed
|
||||||
|
func (q *ExtractionQueue) Enqueue(job *ExtractionJob) *ExtractionJob {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
if q.shutdown {
|
||||||
|
// Queue is shutting down, signal done immediately without executing
|
||||||
|
close(job.done)
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
q.jobs = append(q.jobs, job)
|
||||||
|
q.cond.Signal()
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnqueueAndWait adds a new extraction job to the queue and waits for it to complete
|
||||||
|
func (q *ExtractionQueue) EnqueueAndWait(job *ExtractionJob) {
|
||||||
|
q.Enqueue(job)
|
||||||
|
job.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueLength returns the current number of pending jobs in the queue
|
||||||
|
func (q *ExtractionQueue) QueueLength() int {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
return len(q.jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning returns true if the queue worker is running
|
||||||
|
func (q *ExtractionQueue) IsRunning() bool {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
return q.running
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPendingJob checks if there's a pending job with the given ID
|
||||||
|
func (q *ExtractionQueue) HasPendingJob(id string) bool {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
for _, job := range q.jobs {
|
||||||
|
if job.ID == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemovePendingJob removes a pending job with the given ID from the queue
|
||||||
|
// Returns true if a job was removed, false if not found
|
||||||
|
// Note: This cannot remove a job that is currently being executed
|
||||||
|
func (q *ExtractionQueue) RemovePendingJob(id string) bool {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
for i, job := range q.jobs {
|
||||||
|
if job.ID == id {
|
||||||
|
// Close the done channel to unblock any waiters
|
||||||
|
close(job.done)
|
||||||
|
// Remove from queue
|
||||||
|
q.jobs = append(q.jobs[:i], q.jobs[i+1:]...)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// worker is the main loop that processes jobs sequentially
|
||||||
|
func (q *ExtractionQueue) worker() {
|
||||||
|
defer q.wg.Done()
|
||||||
|
|
||||||
|
for {
|
||||||
|
q.mu.Lock()
|
||||||
|
// Wait for a job or shutdown signal
|
||||||
|
for len(q.jobs) == 0 && !q.shutdown {
|
||||||
|
q.cond.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
if q.shutdown {
|
||||||
|
// Close done channels for all remaining jobs
|
||||||
|
for _, job := range q.jobs {
|
||||||
|
close(job.done)
|
||||||
|
}
|
||||||
|
q.jobs = nil
|
||||||
|
q.running = false
|
||||||
|
q.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dequeue the first job
|
||||||
|
job := q.jobs[0]
|
||||||
|
q.jobs = q.jobs[1:]
|
||||||
|
q.mu.Unlock()
|
||||||
|
|
||||||
|
// Execute the job outside the lock
|
||||||
|
if job.Execute != nil {
|
||||||
|
job.Execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal that the job is done
|
||||||
|
close(job.done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global extraction queue instance
|
||||||
|
var globalExtractionQueue *ExtractionQueue
|
||||||
|
var extractionQueueOnce sync.Once
|
||||||
|
|
||||||
|
// GetExtractionQueue returns the global extraction queue instance
|
||||||
|
func GetExtractionQueue() *ExtractionQueue {
|
||||||
|
extractionQueueOnce.Do(func() {
|
||||||
|
globalExtractionQueue = NewExtractionQueue()
|
||||||
|
globalExtractionQueue.Start()
|
||||||
|
})
|
||||||
|
return globalExtractionQueue
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,46 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/mholt/archives"
|
||||||
|
)
|
||||||
|
|
||||||
|
// extractRarMultiPart extracts a multi-part RAR archive
|
||||||
|
func extractRarMultiPart(firstPartPath string, destDir string, password string, progressCallback ExtractProgressCallback) error {
|
||||||
|
// For RAR archives, we need to use the Name field in archives.Rar
|
||||||
|
// to let it automatically find subsequent volumes
|
||||||
|
|
||||||
|
dir := filepath.Dir(firstPartPath)
|
||||||
|
fileName := filepath.Base(firstPartPath)
|
||||||
|
|
||||||
|
rar := archives.Rar{
|
||||||
|
Password: password,
|
||||||
|
Name: fileName,
|
||||||
|
FS: os.DirFS(dir),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create destination directory
|
||||||
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count files first for progress
|
||||||
|
totalFiles := 0
|
||||||
|
err := rar.Extract(context.Background(), nil, func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||||
|
if !fileInfo.IsDir() {
|
||||||
|
totalFiles++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// If counting fails, proceed without progress
|
||||||
|
totalFiles = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset and extract with progress tracking
|
||||||
|
return rar.Extract(context.Background(), nil, createExtractionHandler(destDir, totalFiles, progressCallback))
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,191 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mholt/archives"
|
||||||
|
)
|
||||||
|
|
||||||
|
// extractZipMultiPart extracts a multi-part ZIP archive (.zip.001, .zip.002, etc.)
|
||||||
|
// These are created by simply splitting a ZIP file into chunks, so we concatenate them
|
||||||
|
func extractZipMultiPart(firstPartPath string, destDir string, password string, progressCallback ExtractProgressCallback) error {
|
||||||
|
// Find all parts
|
||||||
|
parts, err := findZipMultiParts(firstPartPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a multi-part reader that reads across all parts
|
||||||
|
multiReader := newMultiPartFileReader(parts)
|
||||||
|
defer multiReader.Close()
|
||||||
|
|
||||||
|
// Get total size
|
||||||
|
totalSize := multiReader.Size()
|
||||||
|
|
||||||
|
// Create destination directory
|
||||||
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// First pass: count files for progress
|
||||||
|
totalFiles := 0
|
||||||
|
zip := newZipFormat()
|
||||||
|
err = zip.Extract(context.Background(), io.NewSectionReader(multiReader, 0, totalSize), func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||||
|
if !fileInfo.IsDir() {
|
||||||
|
totalFiles++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// If counting fails, proceed without progress
|
||||||
|
totalFiles = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset reader for actual extraction
|
||||||
|
multiReader.Close()
|
||||||
|
multiReader = newMultiPartFileReader(parts)
|
||||||
|
defer multiReader.Close()
|
||||||
|
|
||||||
|
// Second pass: extract with progress tracking
|
||||||
|
return zip.Extract(context.Background(), io.NewSectionReader(multiReader, 0, totalSize), createExtractionHandler(destDir, totalFiles, progressCallback))
|
||||||
|
}
|
||||||
|
|
||||||
|
// findZipMultiParts finds all parts of a multi-part ZIP archive in order
|
||||||
|
func findZipMultiParts(firstPartPath string) ([]string, error) {
|
||||||
|
// Extract base name (e.g., "Archive.zip" from "Archive.zip.001")
|
||||||
|
dir := filepath.Dir(firstPartPath)
|
||||||
|
baseName := filepath.Base(firstPartPath)
|
||||||
|
|
||||||
|
// Remove the .001 suffix to get the base
|
||||||
|
if idx := strings.LastIndex(baseName, "."); idx > 0 {
|
||||||
|
baseName = baseName[:idx] // "Archive.zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
partNum := 1
|
||||||
|
|
||||||
|
for {
|
||||||
|
partPath := filepath.Join(dir, baseName+fmt.Sprintf(".%03d", partNum))
|
||||||
|
if _, err := os.Stat(partPath); os.IsNotExist(err) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parts = append(parts, partPath)
|
||||||
|
partNum++
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return nil, fmt.Errorf("no parts found for %s", firstPartPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// multiPartFileReader provides io.ReaderAt over multiple files concatenated
|
||||||
|
type multiPartFileReader struct {
|
||||||
|
parts []string
|
||||||
|
files []*os.File
|
||||||
|
sizes []int64
|
||||||
|
offsets []int64 // cumulative offsets for each file
|
||||||
|
totalSize int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMultiPartFileReader(parts []string) *multiPartFileReader {
|
||||||
|
return &multiPartFileReader{parts: parts}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *multiPartFileReader) init() error {
|
||||||
|
if m.files != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m.files = make([]*os.File, len(m.parts))
|
||||||
|
m.sizes = make([]int64, len(m.parts))
|
||||||
|
m.offsets = make([]int64, len(m.parts))
|
||||||
|
|
||||||
|
var offset int64
|
||||||
|
for i, part := range m.parts {
|
||||||
|
f, err := os.Open(part)
|
||||||
|
if err != nil {
|
||||||
|
m.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stat, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
f.Close()
|
||||||
|
m.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.files[i] = f
|
||||||
|
m.sizes[i] = stat.Size()
|
||||||
|
m.offsets[i] = offset
|
||||||
|
offset += stat.Size()
|
||||||
|
}
|
||||||
|
m.totalSize = offset
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *multiPartFileReader) Size() int64 {
|
||||||
|
if err := m.init(); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return m.totalSize
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *multiPartFileReader) ReadAt(p []byte, off int64) (n int, err error) {
|
||||||
|
if err := m.init(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if off >= m.totalSize {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find which file(s) to read from
|
||||||
|
for i, fileOffset := range m.offsets {
|
||||||
|
fileEnd := fileOffset + m.sizes[i]
|
||||||
|
|
||||||
|
if off >= fileEnd {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read from this file
|
||||||
|
localOffset := off - fileOffset
|
||||||
|
toRead := len(p) - n
|
||||||
|
if int64(toRead) > fileEnd-off {
|
||||||
|
toRead = int(fileEnd - off)
|
||||||
|
}
|
||||||
|
|
||||||
|
read, err := m.files[i].ReadAt(p[n:n+toRead], localOffset)
|
||||||
|
n += read
|
||||||
|
off += int64(read)
|
||||||
|
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if n >= len(p) {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if n == 0 {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *multiPartFileReader) Close() error {
|
||||||
|
for _, f := range m.files {
|
||||||
|
if f != nil {
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.files = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
+15
-2
@@ -2,15 +2,17 @@ package download
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/internal/controller"
|
"github.com/GopeedLab/gopeed/internal/controller"
|
||||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||||
"github.com/GopeedLab/gopeed/internal/protocol/bt"
|
"github.com/GopeedLab/gopeed/internal/protocol/bt"
|
||||||
|
"github.com/GopeedLab/gopeed/internal/protocol/ed2k"
|
||||||
"github.com/GopeedLab/gopeed/internal/protocol/http"
|
"github.com/GopeedLab/gopeed/internal/protocol/http"
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
"github.com/GopeedLab/gopeed/pkg/util"
|
"github.com/GopeedLab/gopeed/pkg/util"
|
||||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ResolveResult struct {
|
type ResolveResult struct {
|
||||||
@@ -25,6 +27,7 @@ type Task struct {
|
|||||||
Status base.Status `json:"status"`
|
Status base.Status `json:"status"`
|
||||||
Uploading bool `json:"uploading"`
|
Uploading bool `json:"uploading"`
|
||||||
Progress *Progress `json:"progress"`
|
Progress *Progress `json:"progress"`
|
||||||
|
IsCreated bool `json:"isCreated"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
|
||||||
@@ -47,6 +50,7 @@ func NewTask() *Task {
|
|||||||
Status: base.DownloadStatusReady,
|
Status: base.DownloadStatusReady,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
|
IsCreated: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +113,14 @@ func (t *Task) updateUploadSpeed(downloaded int64, usedTime float64) int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func calcSpeed(speedArr *[]int64, downloaded int64, usedTime float64) int64 {
|
func calcSpeed(speedArr *[]int64, downloaded int64, usedTime float64) int64 {
|
||||||
|
if usedTime <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if downloaded < 0 {
|
||||||
|
*speedArr = (*speedArr)[:0]
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
*speedArr = append(*speedArr, downloaded)
|
*speedArr = append(*speedArr, downloaded)
|
||||||
// Record last 5 seconds of download speed to calculate the average speed
|
// Record last 5 seconds of download speed to calculate the average speed
|
||||||
if len(*speedArr) > int(5.0/usedTime) {
|
if len(*speedArr) > int(5.0/usedTime) {
|
||||||
@@ -155,6 +167,7 @@ func (cfg *DownloaderConfig) Init() *DownloaderConfig {
|
|||||||
cfg.FetchManagers = []fetcher.FetcherManager{
|
cfg.FetchManagers = []fetcher.FetcherManager{
|
||||||
new(http.FetcherManager),
|
new(http.FetcherManager),
|
||||||
new(bt.FetcherManager),
|
new(bt.FetcherManager),
|
||||||
|
new(ed2k.FetcherManager),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if cfg.RefreshInterval == 0 {
|
if cfg.RefreshInterval == 0 {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package download
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCalcSpeedResetOnRollback(t *testing.T) {
|
||||||
|
speedArr := []int64{1024, 2048, 4096}
|
||||||
|
|
||||||
|
if got := calcSpeed(&speedArr, -512, 1); got != 0 {
|
||||||
|
t.Fatalf("calcSpeed() = %d, want 0 after rollback", got)
|
||||||
|
}
|
||||||
|
if len(speedArr) != 0 {
|
||||||
|
t.Fatalf("speed window len = %d, want 0 after rollback", len(speedArr))
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := calcSpeed(&speedArr, 1024, 1); got != 1024 {
|
||||||
|
t.Fatalf("calcSpeed() = %d, want 1024 after reset", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 50 of 154 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user