mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-12 19:33:41 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
76bec67ab2 | ||
|
|
a2a9e3dc2a | ||
|
|
9a5680fb45 | ||
|
|
75a8a3a9ac | ||
|
|
3e8c85b945 | ||
|
|
90f70d1bf6 | ||
|
|
20a70d7f91 | ||
|
|
d33546a4b5 |
No files matched your search
@@ -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>
|
||||||
|
|||||||
+119
-29
@@ -14,7 +14,9 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: "1.24"
|
GO_VERSION: "1.24"
|
||||||
FLUTTER_VERSION: "3.24.5"
|
FLUTTER_VERSION: "3.38.7"
|
||||||
|
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-latest
|
|
||||||
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"
|
||||||
$release = "build\windows\x64\runner\Release\"
|
if ("${{ matrix.arch }}" -eq "amd64") {
|
||||||
cp $mingw\libstdc++-6.dll $release
|
# for amd64
|
||||||
cp $mingw\libgcc_s_seh-1.dll $release
|
$release = "build\windows\x64\runner\Release\"
|
||||||
|
$mingw = "C:\Program Files\Git\mingw64\bin"
|
||||||
|
cp $mingw\libstdc++-6.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,9 +184,8 @@ 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
|
||||||
with:
|
with:
|
||||||
@@ -141,7 +194,7 @@ jobs:
|
|||||||
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
|
||||||
@@ -167,7 +220,7 @@ jobs:
|
|||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
channel: [ arm64, amd64, universal ]
|
channel: [arm64, amd64, universal]
|
||||||
needs: [get-release, build-macos-amd64-lib]
|
needs: [get-release, build-macos-amd64-lib]
|
||||||
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
|
||||||
{
|
{
|
||||||
@@ -258,7 +311,7 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-24.04, ubuntu-24.04-arm]
|
os: [ubuntu-22.04, ubuntu-22.04-arm]
|
||||||
needs: [get-release]
|
needs: [get-release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
@@ -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 imagemagick potrace 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,29 @@ jobs:
|
|||||||
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater
|
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater
|
||||||
|
|
||||||
cd ui/flutter
|
cd ui/flutter
|
||||||
|
|
||||||
|
ICON_PATH=$(find assets -name "logo.png" -o -name "icon.png" | head -n 1)
|
||||||
|
[ -z "$ICON_PATH" ] && ICON_PATH="assets/icon.png" && touch assets/icon.png
|
||||||
|
|
||||||
|
convert "$ICON_PATH" assets/icon.svg
|
||||||
|
ICON_PATH="assets/icon.svg"
|
||||||
|
|
||||||
|
RPM_ARCH=$(uname -m)
|
||||||
|
|
||||||
|
mkdir -p linux/packaging/rpm
|
||||||
|
cat << EOF > linux/packaging/rpm/make_config.yaml
|
||||||
|
display_name: Gopeed
|
||||||
|
icon: $ICON_PATH
|
||||||
|
summary: High speed downloader.
|
||||||
|
description: A high speed downloader that supports all platforms.
|
||||||
|
group: Applications/Internet
|
||||||
|
license: GPL-3.0
|
||||||
|
url: https://github.com/GopeedLab/gopeed
|
||||||
|
vendor: GopeedLab
|
||||||
|
maintainer: GopeedLab <[email protected]>
|
||||||
|
build_arch: $RPM_ARCH
|
||||||
|
EOF
|
||||||
|
|
||||||
cat << EOF > distribute_options.yaml
|
cat << EOF > distribute_options.yaml
|
||||||
output: dist/
|
output: dist/
|
||||||
releases:
|
releases:
|
||||||
@@ -300,6 +373,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 +382,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 +405,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 +487,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 +503,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 +575,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 +609,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 +638,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 +755,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.38.7"
|
||||||
|
|
||||||
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-24.04, ubuntu-24.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 ./
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
[](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://docs.gopeed.com/donate.html)
|
||||||
|
[](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>
|
||||||
@@ -24,16 +25,24 @@ Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://
|
|||||||
<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>
|
||||||
@@ -170,6 +179,12 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
|||||||
|
|
||||||
Gopeed also provides a browser extension to take over browser downloads, supporting browsers such as Chrome, Edge, Firefox, etc., please refer to: [https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
|
Gopeed also provides a browser extension to take over browser downloads, supporting browsers such as Chrome, Edge, Firefox, etc., please refer to: [https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
|
||||||
|
|
||||||
|
## 📱 WeChat Official Account
|
||||||
|
|
||||||
|
Follow our WeChat Official Account to get the latest updates and news.
|
||||||
|
|
||||||
|
<img src="_docs/img/weixin.png" width="200" />
|
||||||
|
|
||||||
## 💝 Donate
|
## 💝 Donate
|
||||||
|
|
||||||
If you like this project, please consider [donating](https://docs.gopeed.com/donate.html) to support the development of this project, thank you!
|
If you like this project, please consider [donating](https://docs.gopeed.com/donate.html) to support the development of this project, thank you!
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -5,6 +5,7 @@
|
|||||||
[](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://docs.gopeed.com/donate.html)
|
||||||
|
[](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>
|
||||||
@@ -159,6 +160,12 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
|
|||||||
|
|
||||||
### 🛠️ コマンドツール
|
### 🛠️ コマンドツール
|
||||||
|
|
||||||
|
## 📱 WeChat 公式アカウント
|
||||||
|
|
||||||
|
公式アカウントをフォローして、最新のアップデートやニュースを入手してください。
|
||||||
|
|
||||||
|
<img src="_docs/img/weixin.png" width="200" />
|
||||||
|
|
||||||
## 💝 寄付
|
## 💝 寄付
|
||||||
|
|
||||||
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://docs.gopeed.com/donate.html)をご検討ください!
|
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://docs.gopeed.com/donate.html)をご検討ください!
|
||||||
@@ -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+
|
||||||
|
|
||||||
### 📋 クローン
|
### 📋 クローン
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -5,6 +5,7 @@
|
|||||||
[](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://docs.gopeed.com/donate.html)
|
||||||
|
[](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>
|
||||||
@@ -165,6 +166,12 @@ Sử dụng `go install`:
|
|||||||
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 📱 WeChat Official Account
|
||||||
|
|
||||||
|
Theo dõi tài khoản chính thức để nhận các cập nhật và tin tức mới nhất.
|
||||||
|
|
||||||
|
<img src="_docs/img/weixin.png" width="200" />
|
||||||
|
|
||||||
## 💝 Quyên góp
|
## 💝 Quyên góp
|
||||||
|
|
||||||
Nếu bạn thích dự án này, xin vui lòng xem xét [quyên góp](https://docs.gopeed.com/donate.html) để hỗ trợ phát triển dự án này, cảm ơn bạn!
|
Nếu bạn thích dự án này, xin vui lòng xem xét [quyên góp](https://docs.gopeed.com/donate.html) để hỗ trợ phát triển dự án này, cảm ơn bạn!
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -5,6 +5,7 @@
|
|||||||
[](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://docs.gopeed.com/zh/donate.html)
|
||||||
|
[](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>
|
||||||
@@ -169,6 +170,12 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
|||||||
|
|
||||||
Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、Edge、Firefox 等浏览器,具体请参考:[https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
|
Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、Edge、Firefox 等浏览器,具体请参考:[https://github.com/GopeedLab/browser-extension](https://github.com/GopeedLab/browser-extension)
|
||||||
|
|
||||||
|
## 📱 微信公众号
|
||||||
|
|
||||||
|
关注公众号获取项目最新动态和资讯。
|
||||||
|
|
||||||
|
<img src="_docs/img/weixin.png" width="200" />
|
||||||
|
|
||||||
## 💝 赞助
|
## 💝 赞助
|
||||||
|
|
||||||
如果觉得项目对你有帮助,请考虑[赞助](https://docs.gopeed.com/zh/donate)以支持这个项目的发展,非常感谢!
|
如果觉得项目对你有帮助,请考虑[赞助](https://docs.gopeed.com/zh/donate)以支持这个项目的发展,非常感谢!
|
||||||
@@ -185,8 +192,8 @@ Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、
|
|||||||
|
|
||||||
### 🌍 环境要求
|
### 🌍 环境要求
|
||||||
|
|
||||||
1. Golang 1.23+
|
1. Golang 1.24+
|
||||||
2. Flutter 3.24+
|
2. Flutter 3.38+
|
||||||
|
|
||||||
### 📋 克隆项目
|
### 📋 克隆项目
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -5,6 +5,7 @@
|
|||||||
[](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://docs.gopeed.com/donate.html)
|
||||||
|
[](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>
|
||||||
@@ -166,6 +167,12 @@ Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速
|
|||||||
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 📱 微信公眾號
|
||||||
|
|
||||||
|
關注公眾號獲取項目最新動態和資訊。
|
||||||
|
|
||||||
|
<img src="_docs/img/weixin.png" width="200" />
|
||||||
|
|
||||||
## 💝 贊助
|
## 💝 贊助
|
||||||
|
|
||||||
如果你認為該項目對你有所幫助,請考慮[贊助](https://docs.gopeed.com/zh/donate)以支持該項目的持續發展,謝謝!
|
如果你認為該項目對你有所幫助,請考慮[贊助](https://docs.gopeed.com/zh/donate)以支持該項目的持續發展,謝謝!
|
||||||
@@ -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.
|
After Width: | Height: | Size: 55 KiB |
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"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"
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//go:build !windows
|
||||||
|
// +build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Dial() (net.Conn, error) {
|
||||||
|
// Get binary path
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return net.Dial("unix", filepath.Join(filepath.Dir(exe), "gopeed_host.sock"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/Microsoft/go-winio"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Dial() (net.Conn, error) {
|
||||||
|
return winio.DialPipe(`\\.\pipe\gopeed_host`, nil)
|
||||||
|
}
|
||||||
+110
-25
@@ -1,22 +1,24 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"time"
|
||||||
|
|
||||||
"github.com/pkg/browser"
|
"github.com/pkg/browser"
|
||||||
"github.com/shirou/gopsutil/v4/process"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const identifier = "gopeed"
|
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
|
Meta map[string]any `json:"meta"`
|
||||||
Params json.RawMessage `json:"params"`
|
Params json.RawMessage `json:"params"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,33 +28,117 @@ type Response struct {
|
|||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiMap = map[string]func(params json.RawMessage) (data any, err error){
|
func check() (data bool, err error) {
|
||||||
"ping": func(params json.RawMessage) (data any, err error) {
|
conn, err := Dial()
|
||||||
processes, err := process.Processes()
|
if err != nil {
|
||||||
if err != nil {
|
return false, err
|
||||||
return false, err
|
}
|
||||||
}
|
defer conn.Close()
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
for _, p := range processes {
|
func wakeup(hidden bool) error {
|
||||||
name, err := p.Name()
|
running, _ := check()
|
||||||
if err != nil {
|
if running {
|
||||||
continue
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(strings.ToLower(name), strings.ToLower(identifier)) {
|
uri := "gopeed:"
|
||||||
return true, nil
|
if hidden {
|
||||||
}
|
uri = uri + "?hidden=true"
|
||||||
|
}
|
||||||
|
if err := browser.OpenURL(uri); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
if ok, _ := check(); ok {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return false, nil
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
|
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){
|
||||||
|
"ping": func(message *Message) (data any, err error) {
|
||||||
|
return check()
|
||||||
},
|
},
|
||||||
"create": func(params json.RawMessage) (data any, err error) {
|
"wakeup": func(message *Message) (data any, err error) {
|
||||||
var strParams string
|
silent := false
|
||||||
if err = json.Unmarshal(params, &strParams); err != nil {
|
if v, ok := message.Meta["silent"]; ok {
|
||||||
|
silent, _ = v.(bool)
|
||||||
|
}
|
||||||
|
err = wakeup(silent)
|
||||||
|
return
|
||||||
|
},
|
||||||
|
"create": func(message *Message) (data any, err error) {
|
||||||
|
buf, err := message.Params.MarshalJSON()
|
||||||
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = browser.OpenURL(fmt.Sprintf("%s:///create?params=%s", identifier, strParams))
|
|
||||||
|
silent := false
|
||||||
|
if v, ok := message.Meta["silent"]; ok {
|
||||||
|
silent, _ = v.(bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := wakeup(silent); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := make(map[string]string)
|
||||||
|
if message.Meta != nil {
|
||||||
|
metaJson, _ := json.Marshal(message.Meta)
|
||||||
|
headers["X-Gopeed-Host-Meta"] = string(metaJson)
|
||||||
|
}
|
||||||
|
_, err = postToFlutter("/create", buf, headers, 10*time.Second)
|
||||||
return
|
return
|
||||||
},
|
},
|
||||||
|
"forward": func(message *Message) (data any, err error) {
|
||||||
|
buf, err := message.Params.MarshalJSON()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := postToFlutter("/forward", buf, nil, 60*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// go build -ldflags="-s -w" -o ui/flutter/assets/exec/ github.com/GopeedLab/gopeed/cmd/host
|
// go build -ldflags="-s -w" -o ui/flutter/assets/exec/ github.com/GopeedLab/gopeed/cmd/host
|
||||||
@@ -63,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())
|
||||||
@@ -88,7 +173,7 @@ func main() {
|
|||||||
var data any
|
var data any
|
||||||
var err error
|
var err error
|
||||||
if handler, ok := apiMap[message.Method]; ok {
|
if handler, ok := apiMap[message.Method]; ok {
|
||||||
data, err = handler(message.Params)
|
data, err = handler(&message)
|
||||||
} else {
|
} else {
|
||||||
err = errors.New("Unknown method: " + message.Method)
|
err = errors.New("Unknown method: " + message.Method)
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-6
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -19,6 +18,7 @@ func main() {
|
|||||||
pid := flag.Int("pid", 0, "PID of the process to update")
|
pid := flag.Int("pid", 0, "PID of the process to update")
|
||||||
updateChannel := flag.String("channel", "", "Update channel")
|
updateChannel := flag.String("channel", "", "Update channel")
|
||||||
packagePath := flag.String("asset", "", "Path to the package asset")
|
packagePath := flag.String("asset", "", "Path to the package asset")
|
||||||
|
exeDir := flag.String("exeDir", "", "Directory of the entry executable")
|
||||||
logPath := flag.String("log", "", "Log file path")
|
logPath := flag.String("log", "", "Log file path")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ func main() {
|
|||||||
restart bool
|
restart bool
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
if restart, err = update(*pid, *updateChannel, *packagePath); err != nil {
|
if restart, err = update(*pid, *updateChannel, *packagePath, *exeDir); err != nil {
|
||||||
log.Printf("Update failed: %v\n", err)
|
log.Printf("Update failed: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ func main() {
|
|||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(pid int, updateChannel, packagePath string) (restart bool, err error) {
|
func update(pid int, updateChannel, packagePath, exeDir string) (restart bool, err error) {
|
||||||
killSignalChan := make(chan any, 1)
|
killSignalChan := make(chan any, 1)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -76,9 +76,8 @@ func update(pid int, updateChannel, packagePath string) (restart bool, err error
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
appDir := filepath.Dir(os.Args[0])
|
log.Printf("Updating process updateChannel=%s packagePath=%s exeDir=%s\n", updateChannel, packagePath, exeDir)
|
||||||
log.Printf("Updating process updateChannel=%s packagePath=%s appDir=%s\n", updateChannel, packagePath, appDir)
|
if restart, err = install(killSignalChan, updateChannel, packagePath, exeDir); err != nil {
|
||||||
if restart, err = install(killSignalChan, updateChannel, packagePath, appDir); err != nil {
|
|
||||||
return false, errors.Wrap(err, "failed to install package")
|
return false, errors.Wrap(err, "failed to install package")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,16 +25,20 @@ func installByDmg(killSignalChan chan<- any, packagePath, destDir string) error
|
|||||||
mountPoint := ""
|
mountPoint := ""
|
||||||
for _, line := range strings.Split(string(output), "\n") {
|
for _, line := range strings.Split(string(output), "\n") {
|
||||||
if strings.Contains(line, "/Volumes/") {
|
if strings.Contains(line, "/Volumes/") {
|
||||||
fields := strings.Fields(line)
|
// Find the /Volumes/ path in the line
|
||||||
if len(fields) > 2 {
|
// hdiutil output format: /dev/disk4s1 Apple_HFS /Volumes/Gopeed
|
||||||
mountPoint = fields[len(fields)-1]
|
// or with sequence number: /dev/disk4s1 Apple_HFS /Volumes/Gopeed 1
|
||||||
|
idx := strings.Index(line, "/Volumes/")
|
||||||
|
if idx != -1 {
|
||||||
|
// Extract everything from /Volumes/ onwards and trim whitespace
|
||||||
|
mountPoint = strings.TrimSpace(line[idx:])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if mountPoint == "" {
|
if mountPoint == "" {
|
||||||
return fmt.Errorf("failed to get mount point")
|
return fmt.Errorf("failed to get mount point from hdiutil output: %s", string(output))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detach the mounted DMG
|
// Detach the mounted DMG
|
||||||
@@ -45,7 +49,7 @@ func installByDmg(killSignalChan chan<- any, packagePath, destDir string) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(matches) == 0 {
|
if len(matches) == 0 {
|
||||||
return fmt.Errorf("no .app found in dmg")
|
return fmt.Errorf("no .app found in dmg, mountPoint: %s", mountPoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
killSignalChan <- nil
|
killSignalChan <- nil
|
||||||
|
|||||||
@@ -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,149 @@ 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/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
|
||||||
|
|
||||||
|
|||||||
@@ -20,15 +20,15 @@ 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 (
|
||||||
client *torrent.Client
|
cfg *torrent.ClientConfig
|
||||||
lock sync.Mutex
|
client *torrent.Client
|
||||||
torrentDirMap = make(map[string]string)
|
lock sync.Mutex
|
||||||
ftMap = make(map[string]*fileTorrentImpl)
|
closeCtx context.Context
|
||||||
closeCtx context.Context
|
closeFunc func()
|
||||||
closeFunc func()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Fetcher struct {
|
type Fetcher struct {
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ package bt
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"github.com/GopeedLab/gopeed/internal/controller"
|
|
||||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
|
||||||
"github.com/GopeedLab/gopeed/internal/test"
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
|
|
||||||
gohttp "net/http"
|
gohttp "net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GopeedLab/gopeed/internal/controller"
|
||||||
|
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||||
|
"github.com/GopeedLab/gopeed/internal/test"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFetcher_Resolve_Torrent(t *testing.T) {
|
func TestFetcher_Resolve_Torrent(t *testing.T) {
|
||||||
@@ -30,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)
|
||||||
}
|
}
|
||||||
@@ -80,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)
|
||||||
}
|
}
|
||||||
@@ -110,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)
|
||||||
}
|
}
|
||||||
@@ -144,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)
|
||||||
}
|
}
|
||||||
@@ -155,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)
|
||||||
}
|
}
|
||||||
@@ -238,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,215 +0,0 @@
|
|||||||
// modify from github.com/anacrolix/torrent/storage/file.go
|
|
||||||
|
|
||||||
package bt
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"github.com/anacrolix/torrent/storage"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"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 {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
+1591
-568
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
|
||||||
|
}
|
||||||
+1129
-109
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
|
||||||
|
}
|
||||||
+535
-17
File diff suppressed because it is too large.
Load diff
+71
-11
@@ -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
|
||||||
}
|
}
|
||||||
@@ -177,11 +178,16 @@ type CreateTaskBatchItem struct {
|
|||||||
type DownloaderStoreConfig struct {
|
type DownloaderStoreConfig struct {
|
||||||
FirstLoad bool `json:"-"` // FirstLoad is the flag that the config is first time init and not from store
|
FirstLoad bool `json:"-"` // FirstLoad is the flag that the config is first time init and not from store
|
||||||
|
|
||||||
DownloadDir string `json:"downloadDir"` // DownloadDir is the default directory to save the downloaded files
|
DownloadDir string `json:"downloadDir"` // DownloadDir is the default directory to save the downloaded files
|
||||||
MaxRunning int `json:"maxRunning"` // MaxRunning is the max running download count
|
MaxRunning int `json:"maxRunning"` // MaxRunning is the max running download count
|
||||||
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
|
||||||
|
|||||||
+174
-1
@@ -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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -43,7 +63,17 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
|
|||||||
ProtocolConfig: map[string]any{
|
ProtocolConfig: map[string]any{
|
||||||
"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)
|
||||||
|
|||||||
+618
-64
File diff suppressed because it is too large.
Load diff
+2146
-57
File diff suppressed because it is too large.
Load diff
@@ -131,6 +131,7 @@ type XMLHttpRequest struct {
|
|||||||
responseHeaders http.Header
|
responseHeaders http.Header
|
||||||
aborted bool
|
aborted bool
|
||||||
client *req.Client
|
client *req.Client
|
||||||
|
fingerprint string
|
||||||
|
|
||||||
WithCredentials bool `json:"withCredentials"`
|
WithCredentials bool `json:"withCredentials"`
|
||||||
Upload *XMLHttpRequestUpload `json:"upload"`
|
Upload *XMLHttpRequestUpload `json:"upload"`
|
||||||
@@ -164,7 +165,7 @@ func (xhr *XMLHttpRequest) SetRequestHeader(key, value string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (xhr *XMLHttpRequest) Send(data goja.Value) {
|
func (xhr *XMLHttpRequest) Send(data goja.Value) {
|
||||||
setFingerprint(xhr.client)
|
setFingerprint(xhr.client, xhr.fingerprint)
|
||||||
|
|
||||||
d := xhr.parseData(data)
|
d := xhr.parseData(data)
|
||||||
var (
|
var (
|
||||||
@@ -381,7 +382,8 @@ func Enable(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL,
|
|||||||
}
|
}
|
||||||
|
|
||||||
instance := &XMLHttpRequest{
|
instance := &XMLHttpRequest{
|
||||||
client: client,
|
client: client,
|
||||||
|
fingerprint: util.SafeGet[string](runtime, FingerprintMagicKey),
|
||||||
Upload: &XMLHttpRequestUpload{
|
Upload: &XMLHttpRequestUpload{
|
||||||
EventProp: &EventProp{
|
EventProp: &EventProp{
|
||||||
eventListeners: make(map[string]func(event *ProgressEvent)),
|
eventListeners: make(map[string]func(event *ProgressEvent)),
|
||||||
@@ -395,8 +397,8 @@ func Enable(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL,
|
|||||||
instanceValue.SetPrototype(call.This.Prototype())
|
instanceValue.SetPrototype(call.This.Prototype())
|
||||||
return instanceValue
|
return instanceValue
|
||||||
})
|
})
|
||||||
runtime.Set("__gopeed_setFingerprint", func(fingerprint Fingerprint) {
|
runtime.Set("__gopeed_setFingerprint", func(fingerprint string) {
|
||||||
currentFingerprint = fingerprint
|
runtime.Set(FingerprintMagicKey, fingerprint)
|
||||||
})
|
})
|
||||||
if err := runtime.Set("ProgressEvent", progressEvent); err != nil {
|
if err := runtime.Set("ProgressEvent", progressEvent); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
package xhr
|
package xhr
|
||||||
|
|
||||||
import (
|
import "github.com/imroc/req/v3"
|
||||||
"github.com/imroc/req/v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Fingerprint string
|
type Fingerprint string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
fingerprintChrome Fingerprint = "chrome"
|
FingerprintMagicKey = "__gopeed_xhr_fingerprint"
|
||||||
fingerprintFirefox Fingerprint = "firefox"
|
|
||||||
fingerprintSafari Fingerprint = "safari"
|
fingerprintChrome = "chrome"
|
||||||
|
fingerprintFirefox = "firefox"
|
||||||
|
fingerprintSafari = "safari"
|
||||||
)
|
)
|
||||||
|
|
||||||
var currentFingerprint Fingerprint = ""
|
func setFingerprint(client *req.Client, fingerprint string) {
|
||||||
|
switch fingerprint {
|
||||||
func setFingerprint(client *req.Client) {
|
|
||||||
switch currentFingerprint {
|
|
||||||
case fingerprintChrome:
|
case fingerprintChrome:
|
||||||
client.ImpersonateChrome()
|
client.ImpersonateChrome()
|
||||||
case fingerprintFirefox:
|
case fingerprintFirefox:
|
||||||
|
|||||||
+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": {
|
||||||
|
|||||||
@@ -22,3 +22,16 @@ func AssertError[T error](err error) (t T, r bool) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SafeGet[T any](vm *goja.Runtime, name string) T {
|
||||||
|
v := vm.Get(name)
|
||||||
|
if v == nil {
|
||||||
|
var init T
|
||||||
|
return init
|
||||||
|
}
|
||||||
|
if e, ok := v.Export().(T); ok {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
var init T
|
||||||
|
return init
|
||||||
|
}
|
||||||
+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
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ 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"
|
||||||
@@ -9,8 +12,6 @@ import (
|
|||||||
"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 {
|
||||||
|
|||||||
Loaded 50 of 150 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user