mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-11 10:54:08 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ddbc11378 |
No files matched your search
+26
-54
@@ -40,37 +40,21 @@ template: |
|
|||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="4">🪟 Windows</td>
|
<td rowspan="2">🪟 Windows</td>
|
||||||
<td rowspan="2"><code>EXE</code></td>
|
<td><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://github.com/GopeedLab/gopeed/releases/download/v$NEXT_PATCH_VERSION/Gopeed-v$NEXT_PATCH_VERSION-windows-amd64.zip">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>arm64</td>
|
<td><code>Portable</code></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://github.com/GopeedLab/gopeed/releases/download/v$NEXT_PATCH_VERSION/Gopeed-v$NEXT_PATCH_VERSION-windows-amd64-portable.zip">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>arm64</td>
|
<td>🍎 MacOS</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-arm64-portable.zip">📥</a></td>
|
<td><code>DMG</code></td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td rowspan="3">🍎 MacOS</td>
|
|
||||||
<td rowspan="3"><code>DMG</code></td>
|
|
||||||
<td>universal</td>
|
<td>universal</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-macos.dmg">📥</a></td>
|
<td><a href="https://github.com/GopeedLab/gopeed/releases/download/v$NEXT_PATCH_VERSION/Gopeed-v$NEXT_PATCH_VERSION-macos.dmg">📥</a></td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>amd64</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-macos-amd64.dmg">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>arm64</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-macos-arm64.dmg">📥</a></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="6">🐧 Linux</td>
|
<td rowspan="6">🐧 Linux</td>
|
||||||
@@ -86,44 +70,32 @@ template: |
|
|||||||
<tr>
|
<tr>
|
||||||
<td rowspan="2"><code>DEB</code></td>
|
<td rowspan="2"><code>DEB</code></td>
|
||||||
<td>amd64</td>
|
<td>amd64</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-linux-amd64.deb">📥</a></td>
|
<td><a href="https://github.com/GopeedLab/gopeed/releases/download/v$NEXT_PATCH_VERSION/Gopeed-v$NEXT_PATCH_VERSION-linux-amd64.deb">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>arm64</td>
|
<td>arm64</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-linux-arm64.deb">📥</a></td>
|
<td><a href="https://github.com/GopeedLab/gopeed/releases/download/v$NEXT_PATCH_VERSION/Gopeed-v$NEXT_PATCH_VERSION-linux-arm64.deb">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="2"><code>AppImage</code></td>
|
<td rowspan="2"><code>AppImage</code></td>
|
||||||
<td>amd64</td>
|
<td>amd64</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-linux-amd64.AppImage">📥</a></td>
|
<td><a href="https://github.com/GopeedLab/gopeed/releases/download/v$NEXT_PATCH_VERSION/Gopeed-v$NEXT_PATCH_VERSION-linux-amd64.AppImage">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>arm64</td>
|
<td>arm64</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-linux-arm64.AppImage">📥</a></td>
|
<td><a href="https://github.com/GopeedLab/gopeed/releases/download/v$NEXT_PATCH_VERSION/Gopeed-v$NEXT_PATCH_VERSION-linux-arm64.AppImage">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="4">🤖 Android</td>
|
<td>🤖 Android</td>
|
||||||
<td rowspan="4"><code>APK</code></td>
|
<td><code>APK</code></td>
|
||||||
<td>universal</td>
|
<td>universal</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-android.apk">📥</a></td>
|
<td><a href="https://github.com/GopeedLab/gopeed/releases/download/v$NEXT_PATCH_VERSION/Gopeed-v$NEXT_PATCH_VERSION-android.apk">📥</a></td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>armeabi-v7a</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-android-armeabi-v7a.apk">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>arm64-v8a</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-android-arm64-v8a.apk">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>x86_64</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-android-x86_64.apk">📥</a></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>📱 iOS</td>
|
<td>📱 iOS</td>
|
||||||
<td><code>IPA</code></td>
|
<td><code>IPA</code></td>
|
||||||
<td>universal</td>
|
<td>universal</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-ios.ipa">📥</a></td>
|
<td><a href="https://github.com/GopeedLab/gopeed/releases/download/v$NEXT_PATCH_VERSION/Gopeed-v$NEXT_PATCH_VERSION-ios.ipa">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>🐳 Docker</td>
|
<td>🐳 Docker</td>
|
||||||
@@ -135,47 +107,47 @@ template: |
|
|||||||
<td rowspan="2">💾 Qnap</td>
|
<td rowspan="2">💾 Qnap</td>
|
||||||
<td rowspan="2"><code>QPKG</code></td>
|
<td rowspan="2"><code>QPKG</code></td>
|
||||||
<td>amd64</td>
|
<td>amd64</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-v$NEXT_PATCH_VERSION-qnap-amd64.qpkg">📥</a></td>
|
<td><a href="https://github.com/GopeedLab/gopeed/releases/download/v$NEXT_PATCH_VERSION/gopeed-v$NEXT_PATCH_VERSION-qnap-amd64.qpkg">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>arm64</td>
|
<td>arm64</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-v$NEXT_PATCH_VERSION-qnap-arm64.qpkg">📥</a></td>
|
<td><a href="https://github.com/GopeedLab/gopeed/releases/download/v$NEXT_PATCH_VERSION/gopeed-v$NEXT_PATCH_VERSION-qnap-arm64.qpkg">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="8">🌐 Web</td>
|
<td rowspan="8">🌐 Web</td>
|
||||||
<td rowspan="3"><code>Windows</code></td>
|
<td rowspan="3"><code>Windows</code></td>
|
||||||
<td>amd64</td>
|
<td>amd64</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-windows-amd64.zip">📥</a></td>
|
<td><a href="https://github.com/GopeedLab/gopeed/releases/download/v$NEXT_PATCH_VERSION/gopeed-web-v$NEXT_PATCH_VERSION-windows-amd64.zip">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>arm64</td>
|
<td>arm64</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-windows-arm64.zip">📥</a></td>
|
<td><a href="https://github.com/GopeedLab/gopeed/releases/download/v$NEXT_PATCH_VERSION/gopeed-web-v$NEXT_PATCH_VERSION-windows-arm64.zip">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>386</td>
|
<td>386</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-windows-386.zip">📥</a></td>
|
<td><a href="https://github.com/GopeedLab/gopeed/releases/download/v$NEXT_PATCH_VERSION/gopeed-web-v$NEXT_PATCH_VERSION-windows-386.zip">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="2"><code>MacOS</code></td>
|
<td rowspan="2"><code>MacOS</code></td>
|
||||||
<td>amd64</td>
|
<td>amd64</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-macos-amd64.zip">📥</a></td>
|
<td><a href="https://github.com/GopeedLab/gopeed/releases/download/v$NEXT_PATCH_VERSION/gopeed-web-v$NEXT_PATCH_VERSION-macos-amd64.zip">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>arm64</td>
|
<td>arm64</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-macos-arm64.zip">📥</a></td>
|
<td><a href="https://github.com/GopeedLab/gopeed/releases/download/v$NEXT_PATCH_VERSION/gopeed-web-v$NEXT_PATCH_VERSION-macos-arm64.zip">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="3"><code>Linux</code></td>
|
<td rowspan="3"><code>Linux</code></td>
|
||||||
<td>amd64</td>
|
<td>amd64</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-linux-amd64.zip">📥</a></td>
|
<td><a href="https://github.com/GopeedLab/gopeed/releases/download/v$NEXT_PATCH_VERSION/gopeed-web-v$NEXT_PATCH_VERSION-linux-amd64.zip">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>arm64</td>
|
<td>arm64</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-linux-arm64.zip">📥</a></td>
|
<td><a href="https://github.com/GopeedLab/gopeed/releases/download/v$NEXT_PATCH_VERSION/gopeed-web-v$NEXT_PATCH_VERSION-linux-arm64.zip">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>386</td>
|
<td>386</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=gopeed-web-v$NEXT_PATCH_VERSION-linux-386.zip">📥</a></td>
|
<td><a href="https://github.com/GopeedLab/gopeed/releases/download/v$NEXT_PATCH_VERSION/gopeed-web-v$NEXT_PATCH_VERSION-linux-386.zip">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+68
-177
@@ -13,10 +13,8 @@ on:
|
|||||||
default: "false"
|
default: "false"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: "1.24"
|
GO_VERSION: "1.23"
|
||||||
FLUTTER_VERSION: "3.38.7"
|
FLUTTER_VERSION: "3.24.5"
|
||||||
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
|
|
||||||
GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -36,62 +34,19 @@ 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:
|
||||||
channel: ${{ matrix.flutter_channel || 'stable' }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
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\"
|
||||||
@@ -101,30 +56,21 @@ 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" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
flutter build windows --dart-define="UPDATE_CHANNEL=windowsPortable"
|
||||||
|
$mingw = "C:\Program Files\Git\mingw64\bin"
|
||||||
$system = "C:\Windows\System32"
|
$system = "C:\Windows\System32"
|
||||||
if ("${{ matrix.arch }}" -eq "amd64") {
|
$release = "build\windows\x64\runner\Release\"
|
||||||
# for amd64
|
cp $mingw\libstdc++-6.dll $release
|
||||||
$release = "build\windows\x64\runner\Release\"
|
cp $mingw\libgcc_s_seh-1.dll $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-${{ matrix.arch }}-portable.zip"
|
Compress-Archive -Path "$release*" -DestinationPath "build\windows\Output\Gopeed-$TAG-windows-amd64-portable.zip"
|
||||||
|
|
||||||
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 }}"
|
flutter build windows --dart-define="UPDATE_CHANNEL=windowsInstaller"
|
||||||
cd build/windows
|
cd build/windows
|
||||||
echo @"
|
echo @"
|
||||||
; Script generated by the Inno Setup Script Wizard.
|
; Script generated by the Inno Setup Script Wizard.
|
||||||
@@ -170,7 +116,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: ".\${{ matrix.arch == 'amd64' && 'x64' || 'arm64' }}\runner\Release\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs;
|
Source: ".\x64\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]
|
||||||
@@ -184,17 +130,18 @@ 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-${{ matrix.arch }}.exe"
|
mv "Output\gopeed.exe" "Output\Gopeed-$TAG-windows-amd64.exe"
|
||||||
Compress-Archive -Path "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe" -DestinationPath "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.zip"
|
Compress-Archive -Path "Output\Gopeed-$TAG-windows-amd64.exe" -DestinationPath "Output\Gopeed-$TAG-windows-amd64.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:
|
||||||
upload_url: ${{ needs.get-release.outputs.upload_url }}
|
upload_url: ${{ needs.get-release.outputs.upload_url }}
|
||||||
asset_path: ui/flutter/build/windows/Output/*
|
asset_path: ui/flutter/build/windows/Output/*
|
||||||
overwrite: true
|
overwrite: true
|
||||||
build-macos-amd64-lib:
|
build-macos-arm64-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-15-intel
|
runs-on: macos-latest
|
||||||
needs: [get-release]
|
needs: [get-release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
@@ -210,18 +157,15 @@ jobs:
|
|||||||
go build -ldflags="-w -s" github.com/GopeedLab/gopeed/cmd/updater
|
go build -ldflags="-w -s" github.com/GopeedLab/gopeed/cmd/updater
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: macos-amd64-lib
|
name: macos-arm64-lib
|
||||||
path: |
|
path: |
|
||||||
libgopeed.dylib
|
libgopeed.dylib
|
||||||
host
|
host
|
||||||
updater
|
updater
|
||||||
build-macos:
|
build-macos:
|
||||||
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-latest
|
runs-on: macos-13
|
||||||
strategy:
|
needs: [get-release, build-macos-arm64-lib]
|
||||||
matrix:
|
|
||||||
channel: [arm64, amd64, universal]
|
|
||||||
needs: [get-release, build-macos-amd64-lib]
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v4
|
||||||
@@ -239,47 +183,35 @@ jobs:
|
|||||||
npm install -g appdmg
|
npm install -g appdmg
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: macos-amd64-lib
|
name: macos-arm64-lib
|
||||||
path: ui/flutter/lib-amd64
|
path: ui/flutter/lib-arm64
|
||||||
- if: ${{ matrix.channel == 'arm64' }}
|
|
||||||
name: Build Arm64 Libraries
|
|
||||||
run: |
|
|
||||||
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/macos/Frameworks/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
|
|
||||||
go build -ldflags="-w -s" -o ui/flutter/assets/exec/host github.com/GopeedLab/gopeed/cmd/host
|
|
||||||
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater
|
|
||||||
- if: ${{ matrix.channel == 'amd64' }}
|
|
||||||
name: Build Amd64 Libraries
|
|
||||||
run: |
|
|
||||||
mkdir -p ui/flutter/macos/Frameworks
|
|
||||||
|
|
||||||
cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/macos/Frameworks/libgopeed.dylib
|
|
||||||
cp ui/flutter/lib-amd64/host ui/flutter/assets/exec/host
|
|
||||||
cp ui/flutter/lib-amd64/updater ui/flutter/assets/exec/updater
|
|
||||||
- if: ${{ matrix.channel == 'universal' }}
|
|
||||||
name: Build Universal Libraries
|
|
||||||
run: |
|
|
||||||
mkdir -p ui/flutter/macos/Frameworks
|
|
||||||
|
|
||||||
# arm64 lib
|
|
||||||
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/.temp/libgopeed.dylib.arm64 github.com/GopeedLab/gopeed/bind/desktop
|
|
||||||
go build -ldflags="-w -s" -o ui/flutter/.temp/host.arm64 github.com/GopeedLab/gopeed/cmd/host
|
|
||||||
go build -ldflags="-w -s" -o ui/flutter/.temp/updater.arm64 github.com/GopeedLab/gopeed/cmd/updater
|
|
||||||
|
|
||||||
# amd64 lib
|
|
||||||
cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.amd64
|
|
||||||
cp ui/flutter/lib-amd64/host ui/flutter/.temp/host.amd64
|
|
||||||
cp ui/flutter/lib-amd64/updater ui/flutter/.temp/updater.amd64
|
|
||||||
|
|
||||||
# universal lib
|
|
||||||
lipo -create -output ui/flutter/macos/Frameworks/libgopeed.dylib ui/flutter/.temp/libgopeed.dylib.arm64 ui/flutter/.temp/libgopeed.dylib.amd64
|
|
||||||
lipo -create -output ui/flutter/assets/exec/host ui/flutter/.temp/host.arm64 ui/flutter/.temp/host.amd64
|
|
||||||
lipo -create -output ui/flutter/assets/exec/updater ui/flutter/.temp/updater.arm64 ui/flutter/.temp/updater.amd64
|
|
||||||
- name: Build
|
- name: Build
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.get-release.outputs.version }}
|
VERSION: ${{ needs.get-release.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
cd ui/flutter
|
PROJECT_DIR=$(pwd)
|
||||||
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 }}"
|
|
||||||
|
# amd64 lib
|
||||||
|
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/lib-amd64/libgopeed.dylib github.com/GopeedLab/gopeed/bind/desktop
|
||||||
|
go build -ldflags="-w -s" -o ui/flutter/lib-amd64/host github.com/GopeedLab/gopeed/cmd/host
|
||||||
|
go build -ldflags="-w -s" -o ui/flutter/lib-amd64/updater github.com/GopeedLab/gopeed/cmd/updater
|
||||||
|
# universal binary
|
||||||
|
mkdir -p ui/flutter/macos/Frameworks
|
||||||
|
cp ui/flutter/lib-amd64/libgopeed.dylib ui/flutter/macos/Frameworks/amd64-lib
|
||||||
|
cp ui/flutter/lib-arm64/libgopeed.dylib ui/flutter/macos/Frameworks/arm64-lib
|
||||||
|
cp ui/flutter/lib-amd64/host ui/flutter/macos/Frameworks/amd64-host
|
||||||
|
cp ui/flutter/lib-arm64/host ui/flutter/macos/Frameworks/arm64-host
|
||||||
|
cp ui/flutter/lib-amd64/updater ui/flutter/macos/Frameworks/amd64-updater
|
||||||
|
cp ui/flutter/lib-arm64/updater ui/flutter/macos/Frameworks/arm64-updater
|
||||||
|
cd ui/flutter/macos/Frameworks
|
||||||
|
lipo -create -output libgopeed.dylib amd64-lib arm64-lib
|
||||||
|
lipo -create -output host amd64-host arm64-host
|
||||||
|
lipo -create -output updater amd64-updater arm64-updater
|
||||||
|
rm -rf amd64-lib arm64-lib amd64-host arm64-host amd64-updater arm64-updater
|
||||||
|
mv host ../../assets/exec/host
|
||||||
|
|
||||||
|
cd $PROJECT_DIR/ui/flutter
|
||||||
|
flutter build macos --dart-define="UPDATE_CHANNEL=macosDmg"
|
||||||
cd build/macos/Build/Products/Release
|
cd build/macos/Build/Products/Release
|
||||||
cat>appdmg.json<<EOF
|
cat>appdmg.json<<EOF
|
||||||
{
|
{
|
||||||
@@ -293,13 +225,6 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
mkdir dist
|
mkdir dist
|
||||||
appdmg appdmg.json dist/Gopeed-v$VERSION-macos.dmg
|
appdmg appdmg.json dist/Gopeed-v$VERSION-macos.dmg
|
||||||
|
|
||||||
if [[ "${{ matrix.channel }}" == "arm64" ]]; then
|
|
||||||
mv dist/Gopeed-v$VERSION-macos.dmg dist/Gopeed-v$VERSION-macos-arm64.dmg
|
|
||||||
fi
|
|
||||||
if [[ "${{ matrix.channel }}" == "amd64" ]]; then
|
|
||||||
mv dist/Gopeed-v$VERSION-macos.dmg dist/Gopeed-v$VERSION-macos-amd64.dmg
|
|
||||||
fi
|
|
||||||
- name: Upload
|
- name: Upload
|
||||||
uses: shogo82148/actions-upload-release-asset@v1
|
uses: shogo82148/actions-upload-release-asset@v1
|
||||||
with:
|
with:
|
||||||
@@ -311,22 +236,31 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-22.04, ubuntu-22.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
|
||||||
- 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
|
- if: matrix.os == 'ubuntu-22.04'
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
|
- if: matrix.os == 'ubuntu-22.04-arm'
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: master
|
channel: master
|
||||||
- run: |
|
- run: |
|
||||||
sudo apt update -y
|
sudo apt update -y
|
||||||
sudo apt install -y ninja-build libgtk-3-dev libayatana-appindicator3-1 libayatana-appindicator3-dev rpm patchelf libfuse2 locate imagemagick potrace libkeybinder-3.0-dev
|
# flutter build dependencies
|
||||||
|
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"
|
||||||
|
wget -O appimagetool "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
|
||||||
chmod +x appimagetool
|
chmod +x appimagetool
|
||||||
sudo mv appimagetool /usr/local/bin/
|
sudo mv appimagetool /usr/local/bin/
|
||||||
- name: Build
|
- name: Build
|
||||||
@@ -336,31 +270,8 @@ jobs:
|
|||||||
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
|
go build -tags nosqlite -ldflags="-w -s -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -buildmode=c-shared -o ui/flutter/linux/bundle/lib/libgopeed.so github.com/GopeedLab/gopeed/bind/desktop
|
||||||
go build -ldflags="-w -s" -o ui/flutter/assets/exec/host github.com/GopeedLab/gopeed/cmd/host
|
go build -ldflags="-w -s" -o ui/flutter/assets/exec/host github.com/GopeedLab/gopeed/cmd/host
|
||||||
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:
|
||||||
@@ -373,8 +284,6 @@ 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
|
||||||
@@ -382,30 +291,19 @@ 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"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
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:
|
||||||
@@ -487,7 +385,6 @@ 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
|
||||||
@@ -503,14 +400,13 @@ 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" --dart-define="GA4_MEASUREMENT_ID=$GA4_MEASUREMENT_ID" --dart-define="GA4_API_SECRET=$GA4_API_SECRET"
|
flutter build linux --dart-define="UPDATE_CHANNEL=linuxSnap"
|
||||||
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:
|
||||||
@@ -575,13 +471,9 @@ 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" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
flutter build apk --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-arm64-v8a-release.apk dist/Gopeed-v$VERSION-android-arm64-v8a.apk
|
|
||||||
cp build/app/outputs/flutter-apk/app-x86_64-release.apk dist/Gopeed-v$VERSION-android-x86_64.apk
|
|
||||||
- name: Upload
|
- name: Upload
|
||||||
uses: shogo82148/actions-upload-release-asset@v1
|
uses: shogo82148/actions-upload-release-asset@v1
|
||||||
with:
|
with:
|
||||||
@@ -590,7 +482,7 @@ jobs:
|
|||||||
overwrite: true
|
overwrite: true
|
||||||
build-ios:
|
build-ios:
|
||||||
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios' }}
|
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios' }}
|
||||||
runs-on: macos-14
|
runs-on: macos-latest
|
||||||
needs: [get-release]
|
needs: [get-release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
@@ -609,7 +501,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" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
flutter build ios --no-codesign --dart-define="UPDATE_CHANNEL=iosIpa"
|
||||||
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
|
||||||
@@ -637,9 +529,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.get-release.outputs.version }}
|
VERSION: ${{ needs.get-release.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
|
VERSION=${VERSION:-$(date +%y%m%d%H%M)} # Use current time as version if not specified
|
||||||
cd ui/flutter
|
cd ui/flutter
|
||||||
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 }}"
|
flutter build web --no-web-resources-cdn
|
||||||
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
|
||||||
@@ -755,8 +647,7 @@ 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" --dart-define="GA4_MEASUREMENT_ID=${{ env.GA4_MEASUREMENT_ID }}" --dart-define="GA4_API_SECRET=${{ env.GA4_API_SECRET }}"
|
flutter build web --no-web-resources-cdn --dart-define="UPDATE_CHANNEL=docker"
|
||||||
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
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ on:
|
|||||||
- "Dockerfile"
|
- "Dockerfile"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: "1.24"
|
GO_VERSION: "1.23"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -1,412 +0,0 @@
|
|||||||
// 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);
|
|
||||||
}
|
|
||||||
+14
-57
@@ -25,11 +25,10 @@ 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.23"
|
||||||
FLUTTER_VERSION: "3.38.7"
|
FLUTTER_VERSION: "3.24.5"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# lint:
|
# lint:
|
||||||
@@ -54,68 +53,27 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
- name: Run Unit tests.
|
- name: Run Unit tests.
|
||||||
run: |
|
run: go test $(go list ./... | grep -v /cmd/ | grep -v /bind/ ) -v -covermode=count -coverprofile=coverage.txt
|
||||||
# Get packages for testing and coverage (including cmd/web for flags_test.go)
|
|
||||||
PACKAGES=$(go list ./... | grep -v /bind/ | grep -v -E '/cmd/(?!web)')
|
|
||||||
go test -v -coverpkg=$(echo "$PACKAGES" | paste -sd "," -) -covermode=count -coverprofile=coverage.txt $PACKAGES
|
|
||||||
# Filter out main.go from coverage report to avoid lowering coverage percentage
|
|
||||||
grep -v "main.go" coverage.txt > coverage_filtered.txt || true
|
|
||||||
mv coverage_filtered.txt coverage.txt
|
|
||||||
- uses: codecov/codecov-action@v4
|
- uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
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:
|
||||||
include:
|
os: [ windows-latest, macos-latest, ubuntu-22.04, ubuntu-22.04-arm ]
|
||||||
- os: windows-2022
|
needs: [ test ]
|
||||||
- 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]
|
|
||||||
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:
|
||||||
channel: ${{ matrix.flutter_channel || 'stable' }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
flutter-version: ${{ matrix.flutter_version || env.FLUTTER_VERSION }}
|
channel: master
|
||||||
- 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
|
||||||
@@ -129,7 +87,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 libkeybinder-3.0-dev
|
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-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
|
||||||
@@ -138,8 +96,8 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [macos-14, ubuntu-latest]
|
os: [ macos-latest, ubuntu-latest ]
|
||||||
needs: [test]
|
needs: [ test ]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v4
|
||||||
@@ -171,7 +129,7 @@ jobs:
|
|||||||
build-web:
|
build-web:
|
||||||
name: Build web check
|
name: Build web check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [test]
|
needs: [ test ]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v4
|
||||||
@@ -184,7 +142,6 @@ 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
|
||||||
@@ -192,7 +149,7 @@ jobs:
|
|||||||
build-docker:
|
build-docker:
|
||||||
name: Build docker check
|
name: Build docker check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [test]
|
needs: [ test ]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v4
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.24.11-alpine3.23 AS go
|
FROM golang:1.23.3 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.23
|
FROM alpine:3.18
|
||||||
LABEL maintainer="monkeyWie"
|
LABEL maintainer="monkeyWie"
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=go /app/dist/gopeed ./
|
COPY --from=go /app/dist/gopeed ./
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
[](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>
|
||||||
@@ -25,38 +24,22 @@ Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://
|
|||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="4">🪟 Windows</td>
|
<td rowspan="2">🪟 Windows</td>
|
||||||
<td rowspan="2"><code>EXE</code></td>
|
<td><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>arm64</td>
|
<td><code>Portable</code></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>
|
<tr>
|
||||||
<td>arm64</td>
|
<td>🍎 MacOS</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-arm64-portable.zip">📥</a></td>
|
<td><code>DMG</code></td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td rowspan="3">🍎 MacOS</td>
|
|
||||||
<td rowspan="3"><code>DMG</code></td>
|
|
||||||
<td>universal</td>
|
<td>universal</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td>amd64</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>arm64</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="6">🐧 Linux</td>
|
<td rowspan="6">🐧 Linux</td>
|
||||||
<td><code>Flathub</code></td>
|
<td><code>Flathub</code></td>
|
||||||
@@ -87,22 +70,10 @@ Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://
|
|||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage">📥</a></td>
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="4">🤖 Android</td>
|
<td>🤖 Android</td>
|
||||||
<td rowspan="4"><code>APK</code></td>
|
<td><code>APK</code></td>
|
||||||
<td>universal</td>
|
<td>universal</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk">📥</a></td>
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk">📥</a></td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>armeabi-v7a</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-armeabi-v7a.apk">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>arm64-v8a</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-arm64-v8a.apk">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>x86_64</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-x86_64.apk">📥</a></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>📱 iOS</td>
|
<td>📱 iOS</td>
|
||||||
@@ -179,12 +150,6 @@ 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!
|
||||||
@@ -201,8 +166,8 @@ This project is divided into two parts, the front end uses `flutter`, the back e
|
|||||||
|
|
||||||
### 🌍 Environment
|
### 🌍 Environment
|
||||||
|
|
||||||
1. Golang 1.24+
|
1. Golang 1.23+
|
||||||
2. Flutter 3.38+
|
2. Flutter 3.24+
|
||||||
|
|
||||||
### 📋 Clone
|
### 📋 Clone
|
||||||
|
|
||||||
|
|||||||
+7
-33
@@ -5,7 +5,6 @@
|
|||||||
[](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>
|
||||||
@@ -36,19 +35,11 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
|
|||||||
<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>
|
<tr>
|
||||||
<td rowspan="3">🍎 MacOS</td>
|
<td>🍎 MacOS</td>
|
||||||
<td rowspan="3"><code>DMG</code></td>
|
<td><code>DMG</code></td>
|
||||||
<td>universal</td>
|
<td>universal</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td>amd64</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>arm64</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="6">🐧 Linux</td>
|
<td rowspan="6">🐧 Linux</td>
|
||||||
<td><code>Flathub</code></td>
|
<td><code>Flathub</code></td>
|
||||||
@@ -79,22 +70,10 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
|
|||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage">📥</a></td>
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="4">🤖 Android</td>
|
<td>🤖 Android</td>
|
||||||
<td rowspan="4"><code>APK</code></td>
|
<td><code>APK</code></td>
|
||||||
<td>universal</td>
|
<td>universal</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk">📥</a></td>
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk">📥</a></td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>armeabi-v7a</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-armeabi-v7a.apk">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>arm64-v8a</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-arm64-v8a.apk">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>x86_64</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-x86_64.apk">📥</a></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>📱 iOS</td>
|
<td>📱 iOS</td>
|
||||||
@@ -156,16 +135,11 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
インストールについての詳細は、[インストール](https://docs.gopeed.com/install.html)を参照してください。
|
インストールについての詳細は、[インストール](https://docs.gopeed.com/install.html)を参照してください。
|
||||||
|
|
||||||
### 🛠️ コマンドツール
|
### 🛠️ コマンドツール
|
||||||
|
|
||||||
## 📱 WeChat 公式アカウント
|
|
||||||
|
|
||||||
公式アカウントをフォローして、最新のアップデートやニュースを入手してください。
|
|
||||||
|
|
||||||
<img src="_docs/img/weixin.png" width="200" />
|
|
||||||
|
|
||||||
## 💝 寄付
|
## 💝 寄付
|
||||||
|
|
||||||
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://docs.gopeed.com/donate.html)をご検討ください!
|
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://docs.gopeed.com/donate.html)をご検討ください!
|
||||||
@@ -182,8 +156,8 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
|
|||||||
|
|
||||||
### 🌍 環境
|
### 🌍 環境
|
||||||
|
|
||||||
1. Go 言語 1.24+
|
1. Go 言語 1.23+
|
||||||
2. Flutter 3.38+
|
2. Flutter 3.24+
|
||||||
|
|
||||||
### 📋 クローン
|
### 📋 クローン
|
||||||
|
|
||||||
|
|||||||
+7
-33
@@ -5,7 +5,6 @@
|
|||||||
[](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>
|
||||||
@@ -36,19 +35,11 @@ Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu
|
|||||||
<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>
|
<tr>
|
||||||
<td rowspan="3">🍎 MacOS</td>
|
<td>🍎 MacOS</td>
|
||||||
<td rowspan="3"><code>DMG</code></td>
|
<td><code>DMG</code></td>
|
||||||
<td>universal</td>
|
<td>universal</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td>amd64</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>arm64</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="6">🐧 Linux</td>
|
<td rowspan="6">🐧 Linux</td>
|
||||||
<td><code>Flathub</code></td>
|
<td><code>Flathub</code></td>
|
||||||
@@ -79,22 +70,10 @@ Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu
|
|||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage">📥</a></td>
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="4">🤖 Android</td>
|
<td>🤖 Android</td>
|
||||||
<td rowspan="4"><code>APK</code></td>
|
<td><code>APK</code></td>
|
||||||
<td>universal</td>
|
<td>universal</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk">📥</a></td>
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk">📥</a></td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>armeabi-v7a</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-armeabi-v7a.apk">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>arm64-v8a</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-arm64-v8a.apk">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>x86_64</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-x86_64.apk">📥</a></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>📱 iOS</td>
|
<td>📱 iOS</td>
|
||||||
@@ -156,6 +135,7 @@ Truy cập ✈ [Trang web chính thức](https://gopeed.com) | 📖 [Tài liệu
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
Thêm thông tin về cài đặt, vui lòng tham khảo [Cài đặt](https://docs.gopeed.com/install.html)
|
Thêm thông tin về cài đặt, vui lòng tham khảo [Cài đặt](https://docs.gopeed.com/install.html)
|
||||||
|
|
||||||
### 🛠️ Công cụ lệnh
|
### 🛠️ Công cụ lệnh
|
||||||
@@ -166,12 +146,6 @@ 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!
|
||||||
@@ -188,8 +162,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.24+
|
1. Golang 1.23+
|
||||||
2. Flutter 3.38+
|
2. Flutter 3.24+
|
||||||
|
|
||||||
### 📋 Clone
|
### 📋 Clone
|
||||||
|
|
||||||
|
|||||||
+7
-33
@@ -5,7 +5,6 @@
|
|||||||
[](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>
|
||||||
@@ -36,19 +35,11 @@ Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不
|
|||||||
<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>
|
<tr>
|
||||||
<td rowspan="3">🍎 MacOS</td>
|
<td>🍎 MacOS</td>
|
||||||
<td rowspan="3"><code>DMG</code></td>
|
<td><code>DMG</code></td>
|
||||||
<td>universal</td>
|
<td>universal</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td>amd64</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>arm64</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="6">🐧 Linux</td>
|
<td rowspan="6">🐧 Linux</td>
|
||||||
<td><code>Flathub</code></td>
|
<td><code>Flathub</code></td>
|
||||||
@@ -79,22 +70,10 @@ Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不
|
|||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage">📥</a></td>
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="4">🤖 Android</td>
|
<td>🤖 Android</td>
|
||||||
<td rowspan="4"><code>APK</code></td>
|
<td><code>APK</code></td>
|
||||||
<td>universal</td>
|
<td>universal</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk">📥</a></td>
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk">📥</a></td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>armeabi-v7a</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-armeabi-v7a.apk">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>arm64-v8a</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-arm64-v8a.apk">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>x86_64</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-x86_64.apk">📥</a></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>📱 iOS</td>
|
<td>📱 iOS</td>
|
||||||
@@ -156,6 +135,7 @@ Gopeed(全称 Go Speed),直译过来中文名叫做`够快下载器`(不
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
更多关于安装的内容请参考[安装文档](https://docs.gopeed.com/zh/install.html)
|
更多关于安装的内容请参考[安装文档](https://docs.gopeed.com/zh/install.html)
|
||||||
|
|
||||||
### 🛠️ 命令行工具
|
### 🛠️ 命令行工具
|
||||||
@@ -170,12 +150,6 @@ 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)以支持这个项目的发展,非常感谢!
|
||||||
@@ -192,8 +166,8 @@ Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、
|
|||||||
|
|
||||||
### 🌍 环境要求
|
### 🌍 环境要求
|
||||||
|
|
||||||
1. Golang 1.24+
|
1. Golang 1.23+
|
||||||
2. Flutter 3.38+
|
2. Flutter 3.24+
|
||||||
|
|
||||||
### 📋 克隆项目
|
### 📋 克隆项目
|
||||||
|
|
||||||
|
|||||||
+6
-33
@@ -5,7 +5,6 @@
|
|||||||
[](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>
|
||||||
@@ -36,19 +35,11 @@ Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速
|
|||||||
<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>
|
<tr>
|
||||||
<td rowspan="3">🍎 MacOS</td>
|
<td>🍎 MacOS</td>
|
||||||
<td rowspan="3"><code>DMG</code></td>
|
<td><code>DMG</code></td>
|
||||||
<td>universal</td>
|
<td>universal</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos.dmg">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td>amd64</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-amd64.dmg">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>arm64</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-macos-arm64.dmg">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="6">🐧 Linux</td>
|
<td rowspan="6">🐧 Linux</td>
|
||||||
<td><code>Flathub</code></td>
|
<td><code>Flathub</code></td>
|
||||||
@@ -79,22 +70,10 @@ Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速
|
|||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage">📥</a></td>
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-linux-arm64.AppImage">📥</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="4">🤖 Android</td>
|
<td>🤖 Android</td>
|
||||||
<td rowspan="4"><code>APK</code></td>
|
<td><code>APK</code></td>
|
||||||
<td>universal</td>
|
<td>universal</td>
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk">📥</a></td>
|
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android.apk">📥</a></td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>armeabi-v7a</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-armeabi-v7a.apk">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>arm64-v8a</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-arm64-v8a.apk">📥</a></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>x86_64</td>
|
|
||||||
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-android-x86_64.apk">📥</a></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>📱 iOS</td>
|
<td>📱 iOS</td>
|
||||||
@@ -167,12 +146,6 @@ 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)以支持該項目的持續發展,謝謝!
|
||||||
@@ -189,8 +162,8 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
|
|||||||
|
|
||||||
### 🌍 開發環境
|
### 🌍 開發環境
|
||||||
|
|
||||||
1. Golang 1.24+
|
1. Golang 1.23+
|
||||||
2. Flutter 3.38+
|
2. Flutter 3.24+
|
||||||
|
|
||||||
### 📋 克隆項目
|
### 📋 克隆項目
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB |
@@ -2,7 +2,6 @@ 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"
|
||||||
|
|||||||
+2
-3
@@ -2,14 +2,13 @@ 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
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
//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"))
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
|
|
||||||
"github.com/Microsoft/go-winio"
|
|
||||||
)
|
|
||||||
|
|
||||||
func Dial() (net.Conn, error) {
|
|
||||||
return winio.DialPipe(`\\.\pipe\gopeed_host`, nil)
|
|
||||||
}
|
|
||||||
+26
-111
@@ -1,24 +1,22 @@
|
|||||||
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"
|
||||||
"time"
|
"strings"
|
||||||
|
|
||||||
"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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,117 +26,33 @@ type Response struct {
|
|||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func check() (data bool, err error) {
|
var apiMap = map[string]func(params json.RawMessage) (data any, err error){
|
||||||
conn, err := Dial()
|
"ping": func(params json.RawMessage) (data any, err error) {
|
||||||
if err != nil {
|
processes, err := process.Processes()
|
||||||
return false, err
|
if err != nil {
|
||||||
}
|
return false, err
|
||||||
defer conn.Close()
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func wakeup(hidden bool) error {
|
|
||||||
running, _ := check()
|
|
||||||
if running {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
uri := "gopeed:"
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("start gopeed failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// postToFlutter sends a POST request to Flutter RPC server
|
for _, p := range processes {
|
||||||
func postToFlutter(path string, body []byte, headers map[string]string, timeout time.Duration) (*http.Response, error) {
|
name, err := p.Name()
|
||||||
client := &http.Client{
|
if err != nil {
|
||||||
Transport: &http.Transport{
|
continue
|
||||||
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){
|
if strings.Contains(strings.ToLower(name), strings.ToLower(identifier)) {
|
||||||
"ping": func(message *Message) (data any, err error) {
|
return true, nil
|
||||||
return check()
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
},
|
},
|
||||||
"wakeup": func(message *Message) (data any, err error) {
|
"create": func(params json.RawMessage) (data any, err error) {
|
||||||
silent := false
|
var strParams string
|
||||||
if v, ok := message.Meta["silent"]; ok {
|
if err = json.Unmarshal(params, &strParams); err != nil {
|
||||||
silent, _ = v.(bool)
|
return
|
||||||
}
|
}
|
||||||
err = wakeup(silent)
|
err = browser.OpenURL(fmt.Sprintf("%s:///create?params=%s", identifier, strParams))
|
||||||
return
|
return
|
||||||
},
|
},
|
||||||
"create": func(message *Message) (data any, err error) {
|
|
||||||
buf, err := message.Params.MarshalJSON()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
},
|
|
||||||
"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
|
||||||
@@ -149,6 +63,7 @@ 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())
|
||||||
@@ -173,7 +88,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)
|
data, err = handler(message.Params)
|
||||||
} else {
|
} else {
|
||||||
err = errors.New("Unknown method: " + message.Method)
|
err = errors.New("Unknown method: " + message.Method)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-5
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,7 +19,6 @@ 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, *exeDir); err != nil {
|
if restart, err = update(*pid, *updateChannel, *packagePath); 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, exeDir string) (restart bool, err error) {
|
func update(pid int, updateChannel, packagePath string) (restart bool, err error) {
|
||||||
killSignalChan := make(chan any, 1)
|
killSignalChan := make(chan any, 1)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -76,8 +76,9 @@ func update(pid int, updateChannel, packagePath, exeDir string) (restart bool, e
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
log.Printf("Updating process updateChannel=%s packagePath=%s exeDir=%s\n", updateChannel, packagePath, exeDir)
|
appDir := filepath.Dir(os.Args[0])
|
||||||
if restart, err = install(killSignalChan, updateChannel, packagePath, exeDir); err != nil {
|
log.Printf("Updating process updateChannel=%s packagePath=%s appDir=%s\n", updateChannel, packagePath, appDir)
|
||||||
|
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,20 +25,16 @@ 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/") {
|
||||||
// Find the /Volumes/ path in the line
|
fields := strings.Fields(line)
|
||||||
// hdiutil output format: /dev/disk4s1 Apple_HFS /Volumes/Gopeed
|
if len(fields) > 2 {
|
||||||
// or with sequence number: /dev/disk4s1 Apple_HFS /Volumes/Gopeed 1
|
mountPoint = fields[len(fields)-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 from hdiutil output: %s", string(output))
|
return fmt.Errorf("failed to get mount point")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detach the mounted DMG
|
// Detach the mounted DMG
|
||||||
@@ -49,7 +45,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, mountPoint: %s", mountPoint)
|
return fmt.Errorf("no .app found in dmg")
|
||||||
}
|
}
|
||||||
|
|
||||||
killSignalChan <- nil
|
killSignalChan <- nil
|
||||||
|
|||||||
+51
-166
@@ -3,23 +3,19 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type args struct {
|
type args struct {
|
||||||
Address *string `json:"address"`
|
Address *string `json:"address"`
|
||||||
Port *int `json:"port"`
|
Port *int `json:"port"`
|
||||||
Username *string `json:"username"`
|
Username *string `json:"username"`
|
||||||
Password *string `json:"password"`
|
Password *string `json:"password"`
|
||||||
ApiToken *string `json:"apiToken"`
|
ApiToken *string `json:"apiToken"`
|
||||||
StorageDir *string `json:"storageDir"`
|
StorageDir *string `json:"storageDir"`
|
||||||
WhiteDownloadDirs []string `json:"whiteDownloadDirs"`
|
|
||||||
// DownloadConfig when the first time to start the server, it will be configured as initial value
|
// DownloadConfig when the first time to start the server, it will be configured as initial value
|
||||||
DownloadConfig *base.DownloaderStoreConfig `json:"downloadConfig"`
|
DownloadConfig *base.DownloaderStoreConfig `json:"downloadConfig"`
|
||||||
|
|
||||||
@@ -27,173 +23,62 @@ type args struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parse() *args {
|
func parse() *args {
|
||||||
cfg := &args{}
|
var cliArgs args
|
||||||
|
cliArgs.Address = flag.String("A", "0.0.0.0", "Bind Address")
|
||||||
cliConfig := loadCliArgs()
|
cliArgs.Port = flag.Int("P", 9999, "Bind Port")
|
||||||
loadConfigFile(cfg, *cliConfig.configPath)
|
cliArgs.Username = flag.String("u", "gopeed", "HTTP Basic Auth Username")
|
||||||
loadEnvVars(cfg)
|
cliArgs.Password = flag.String("p", "", "HTTP Basic Auth Pwd")
|
||||||
// override with non-default command line arguments
|
cliArgs.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling basic authentication")
|
||||||
overrideWithCliArgs(cfg, cliConfig)
|
cliArgs.StorageDir = flag.String("d", "", "Storage directory")
|
||||||
// set default values
|
cliArgs.configPath = flag.String("c", "./config.json", "Config file path")
|
||||||
setDefaults(cfg, cliConfig)
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadCliArgs parses command line arguments and returns initial config
|
|
||||||
func loadCliArgs() *args {
|
|
||||||
cfg := &args{}
|
|
||||||
cfg.Address = flag.String("A", "0.0.0.0", "Bind Address")
|
|
||||||
cfg.Port = flag.Int("P", 9999, "Bind Port")
|
|
||||||
cfg.Username = flag.String("u", "gopeed", "Web Authentication Username")
|
|
||||||
cfg.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
|
|
||||||
cfg.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
|
|
||||||
cfg.StorageDir = flag.String("d", "", "Storage directory")
|
|
||||||
whiteDownloadDirs := flag.String("w", "", "White download directories, comma-separated")
|
|
||||||
cfg.configPath = flag.String("c", "./config.json", "Config file path")
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// Parse white download directories from comma-separated string
|
// args priority: config file > cli args
|
||||||
if whiteDownloadDirs != nil && *whiteDownloadDirs != "" {
|
cfgArgs := loadConfig(*cliArgs.configPath)
|
||||||
dirs := strings.Split(*whiteDownloadDirs, ",")
|
if cfgArgs.Address == nil {
|
||||||
for i := range dirs {
|
cfgArgs.Address = cliArgs.Address
|
||||||
dirs[i] = strings.TrimSpace(dirs[i])
|
|
||||||
}
|
|
||||||
cfg.WhiteDownloadDirs = dirs
|
|
||||||
}
|
}
|
||||||
|
if cfgArgs.Port == nil {
|
||||||
return cfg
|
cfgArgs.Port = cliArgs.Port
|
||||||
|
}
|
||||||
|
if cfgArgs.Username == nil {
|
||||||
|
cfgArgs.Username = cliArgs.Username
|
||||||
|
}
|
||||||
|
if cfgArgs.Password == nil {
|
||||||
|
cfgArgs.Password = cliArgs.Password
|
||||||
|
}
|
||||||
|
if cfgArgs.ApiToken == nil {
|
||||||
|
cfgArgs.ApiToken = cliArgs.ApiToken
|
||||||
|
}
|
||||||
|
if cfgArgs.StorageDir == nil {
|
||||||
|
cfgArgs.StorageDir = cliArgs.StorageDir
|
||||||
|
}
|
||||||
|
return cfgArgs
|
||||||
}
|
}
|
||||||
|
|
||||||
// overrideWithCliArgs overrides config with non-empty command line arguments
|
func loadConfig(path string) *args {
|
||||||
func overrideWithCliArgs(cfg *args, cliConfig *args) {
|
var args args
|
||||||
flag.Visit(func(f *flag.Flag) {
|
|
||||||
switch f.Name {
|
|
||||||
case "A":
|
|
||||||
cfg.Address = cliConfig.Address
|
|
||||||
case "P":
|
|
||||||
cfg.Port = cliConfig.Port
|
|
||||||
case "u":
|
|
||||||
cfg.Username = cliConfig.Username
|
|
||||||
case "p":
|
|
||||||
cfg.Password = cliConfig.Password
|
|
||||||
case "T":
|
|
||||||
cfg.ApiToken = cliConfig.ApiToken
|
|
||||||
case "d":
|
|
||||||
cfg.StorageDir = cliConfig.StorageDir
|
|
||||||
case "w":
|
|
||||||
cfg.WhiteDownloadDirs = cliConfig.WhiteDownloadDirs
|
|
||||||
case "c":
|
|
||||||
cfg.configPath = cliConfig.configPath
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// setDefaults sets default values for any unset configuration fields
|
if !filepath.IsAbs(path) {
|
||||||
func setDefaults(cfg *args, cliConfig *args) {
|
|
||||||
if cfg.Address == nil {
|
|
||||||
cfg.Address = cliConfig.Address
|
|
||||||
}
|
|
||||||
if cfg.Port == nil {
|
|
||||||
cfg.Port = cliConfig.Port
|
|
||||||
}
|
|
||||||
if cfg.Username == nil {
|
|
||||||
cfg.Username = cliConfig.Username
|
|
||||||
}
|
|
||||||
if cfg.Password == nil {
|
|
||||||
cfg.Password = cliConfig.Password
|
|
||||||
}
|
|
||||||
if cfg.ApiToken == nil {
|
|
||||||
cfg.ApiToken = cliConfig.ApiToken
|
|
||||||
}
|
|
||||||
if cfg.StorageDir == nil {
|
|
||||||
cfg.StorageDir = cliConfig.StorageDir
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadConfigFile loads configuration from file
|
|
||||||
func loadConfigFile(cfg *args, configPath string) {
|
|
||||||
if !filepath.IsAbs(configPath) {
|
|
||||||
dir, err := os.Getwd()
|
dir, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
fmt.Println("config dir get failed, reason:" + err.Error())
|
||||||
|
return &args
|
||||||
}
|
}
|
||||||
configPath = filepath.Join(dir, configPath)
|
path = filepath.Join(dir, path)
|
||||||
}
|
}
|
||||||
|
file, err := os.ReadFile(path)
|
||||||
file, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return
|
return &args
|
||||||
}
|
}
|
||||||
return
|
fmt.Println("config file read failed, reason:" + err.Error())
|
||||||
|
return &args
|
||||||
}
|
}
|
||||||
|
if err = json.Unmarshal(file, &args); err != nil {
|
||||||
if err = json.Unmarshal(file, cfg); err != nil {
|
fmt.Println("config file parse failed, reason:" + err.Error())
|
||||||
return
|
return &args
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadEnvVars loads configuration from environment variables with prefix GOPEED_
|
|
||||||
func loadEnvVars(cfg *args) {
|
|
||||||
v := reflect.ValueOf(cfg).Elem()
|
|
||||||
t := reflect.TypeOf(cfg).Elem()
|
|
||||||
|
|
||||||
for i := 0; i < v.NumField(); i++ {
|
|
||||||
field := v.Field(i)
|
|
||||||
fieldType := t.Field(i)
|
|
||||||
|
|
||||||
// Get json tag as environment variable suffix
|
|
||||||
jsonTag := fieldType.Tag.Get("json")
|
|
||||||
if jsonTag == "" || jsonTag == "-" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove options like omitempty
|
|
||||||
if commaIdx := strings.Index(jsonTag, ","); commaIdx != -1 {
|
|
||||||
jsonTag = jsonTag[:commaIdx]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to uppercase and add GOPEED_ prefix
|
|
||||||
envKey := "GOPEED_" + strings.ToUpper(jsonTag)
|
|
||||||
envValue := os.Getenv(envKey)
|
|
||||||
|
|
||||||
if envValue == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set value based on field type
|
|
||||||
if field.Kind() == reflect.Ptr {
|
|
||||||
if field.IsNil() {
|
|
||||||
// Create new pointer instance
|
|
||||||
newVal := reflect.New(field.Type().Elem())
|
|
||||||
field.Set(newVal)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch field.Type().Elem().Kind() {
|
|
||||||
case reflect.String:
|
|
||||||
field.Elem().SetString(envValue)
|
|
||||||
case reflect.Int:
|
|
||||||
if intVal, err := strconv.Atoi(envValue); err == nil {
|
|
||||||
field.Elem().SetInt(int64(intVal))
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// For complex types like DownloadConfig, try JSON unmarshaling
|
|
||||||
if field.Type().Elem() == reflect.TypeOf(base.DownloaderStoreConfig{}) {
|
|
||||||
var config base.DownloaderStoreConfig
|
|
||||||
if err := json.Unmarshal([]byte(envValue), &config); err == nil {
|
|
||||||
field.Set(reflect.ValueOf(&config))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if field.Kind() == reflect.Slice {
|
|
||||||
// Handle non-pointer slice types (like []string for WhiteDownloadDirs)
|
|
||||||
if field.Type().Elem().Kind() == reflect.String {
|
|
||||||
dirs := strings.Split(envValue, ",")
|
|
||||||
for i := range dirs {
|
|
||||||
dirs[i] = strings.TrimSpace(dirs[i])
|
|
||||||
}
|
|
||||||
field.Set(reflect.ValueOf(dirs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
fmt.Printf("config file loaded: %s\n", path)
|
||||||
|
return &args
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
+17
-19
@@ -6,12 +6,11 @@ package main
|
|||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/GopeedLab/gopeed/cmd"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/cmd"
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed dist/*
|
//go:embed dist/*
|
||||||
@@ -24,37 +23,36 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
args := parse()
|
args := parse()
|
||||||
var webAuth *model.WebAuth
|
var webBasicAuth *model.WebBasicAuth
|
||||||
if isNotBlank(args.Username) && isNotBlank(args.Password) {
|
if isNotBlank(args.Username) && isNotBlank(args.Password) {
|
||||||
webAuth = &model.WebAuth{
|
webBasicAuth = &model.WebBasicAuth{
|
||||||
Username: *args.Username,
|
Username: *args.Username,
|
||||||
Password: *args.Password,
|
Password: *args.Password,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var storageDir string
|
var dir string
|
||||||
if args.StorageDir != nil && *args.StorageDir != "" {
|
if args.StorageDir != nil && *args.StorageDir != "" {
|
||||||
storageDir = *args.StorageDir
|
dir = *args.StorageDir
|
||||||
} else {
|
} else {
|
||||||
exe, err := os.Executable()
|
exe, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
storageDir = filepath.Join(filepath.Dir(exe), "storage")
|
dir = filepath.Dir(exe)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &model.StartConfig{
|
cfg := &model.StartConfig{
|
||||||
Network: "tcp",
|
Network: "tcp",
|
||||||
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
|
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
|
||||||
Storage: model.StorageBolt,
|
Storage: model.StorageBolt,
|
||||||
StorageDir: storageDir,
|
StorageDir: filepath.Join(dir, "storage"),
|
||||||
WhiteDownloadDirs: args.WhiteDownloadDirs,
|
ApiToken: *args.ApiToken,
|
||||||
ApiToken: *args.ApiToken,
|
DownloadConfig: args.DownloadConfig,
|
||||||
DownloadConfig: args.DownloadConfig,
|
ProductionMode: true,
|
||||||
ProductionMode: true,
|
WebEnable: true,
|
||||||
WebEnable: true,
|
WebFS: sub,
|
||||||
WebFS: sub,
|
WebBasicAuth: webBasicAuth,
|
||||||
WebAuth: webAuth,
|
|
||||||
}
|
}
|
||||||
cmd.Start(cfg)
|
cmd.Start(cfg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,164 +1,139 @@
|
|||||||
module github.com/GopeedLab/gopeed
|
module github.com/GopeedLab/gopeed
|
||||||
|
|
||||||
go 1.24.9
|
go 1.23.0
|
||||||
|
|
||||||
toolchain go1.24.11
|
toolchain go1.23.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/anacrolix/torrent v1.60.1-0.20251217073903-486bcbe758e0
|
github.com/anacrolix/missinggo/v2 v2.8.0
|
||||||
|
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/bodgit/sevenzip v1.6.1
|
github.com/dop251/goja v0.0.0-20240919115326-6c7d1df7ff05
|
||||||
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
|
||||||
github.com/gorilla/mux v1.8.0
|
github.com/gorilla/mux v1.8.0
|
||||||
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.4.3
|
go.etcd.io/bbolt v1.3.11
|
||||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
|
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f
|
||||||
|
golang.org/x/sync v0.9.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
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/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c // indirect
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c // indirect
|
||||||
github.com/bodgit/plumbing v1.3.0 // indirect
|
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||||
github.com/bodgit/windows v1.0.1 // indirect
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // 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/google/go-cmp v0.7.0 // indirect
|
|
||||||
github.com/hashicorp/errwrap v1.1.0 // 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/klauspost/compress v1.18.0 // 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/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/quic-go v0.51.0 // indirect
|
|
||||||
github.com/refraction-networking/utls v1.6.7 // indirect
|
|
||||||
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
|
||||||
github.com/spf13/afero v1.15.0 // 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/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
|
github.com/Microsoft/go-winio v0.6.1 // indirect
|
||||||
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.7.0 // indirect
|
github.com/anacrolix/chansync v0.6.0 // indirect
|
||||||
github.com/anacrolix/dht/v2 v2.23.0 // indirect
|
github.com/anacrolix/dht/v2 v2.22.0 // indirect
|
||||||
github.com/anacrolix/envpprof v1.5.0 // indirect
|
github.com/anacrolix/envpprof v1.4.0 // indirect
|
||||||
github.com/anacrolix/generics v0.2.0 // indirect
|
github.com/anacrolix/generics v0.0.3-0.20240902042256-7fb2702ef0ca // indirect
|
||||||
github.com/anacrolix/go-libutp v1.3.2 // indirect
|
github.com/anacrolix/go-libutp v1.3.1 // indirect
|
||||||
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb // indirect
|
github.com/anacrolix/log v0.16.0 // 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.6.0 // indirect
|
github.com/anacrolix/sync v0.5.3 // 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.24.4 // indirect
|
github.com/bits-and-blooms/bitset v1.15.0 // 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.3.3 // 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.5 // indirect
|
github.com/dlclark/regexp2 v1.11.4 // 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.2.0 // indirect
|
github.com/go-llsqlite/adapter v0.1.0 // indirect
|
||||||
github.com/go-llsqlite/crawshaw v0.6.0 // indirect
|
github.com/go-llsqlite/crawshaw v0.5.5 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.2 // 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-20260115054156-294ebfa9ad83 // indirect
|
github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134 // 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.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.9 // 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.1.0 // indirect
|
github.com/multiformats/go-varint v0.0.7 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/pion/datachannel v1.6.0 // indirect
|
github.com/pion/datachannel v1.5.9 // indirect
|
||||||
github.com/pion/dtls/v3 v3.0.10 // indirect
|
github.com/pion/dtls/v3 v3.0.4 // indirect
|
||||||
github.com/pion/ice/v4 v4.2.0 // indirect
|
github.com/pion/ice/v4 v4.0.2 // indirect
|
||||||
github.com/pion/interceptor v0.1.42 // indirect
|
github.com/pion/interceptor v0.1.37 // indirect
|
||||||
github.com/pion/logging v0.2.4 // indirect
|
github.com/pion/logging v0.2.2 // indirect
|
||||||
github.com/pion/mdns/v2 v2.1.0 // indirect
|
github.com/pion/mdns/v2 v2.0.7 // indirect
|
||||||
github.com/pion/randutil v0.1.0 // indirect
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
github.com/pion/rtcp v1.2.16 // indirect
|
github.com/pion/rtcp v1.2.14 // indirect
|
||||||
github.com/pion/rtp v1.10.0 // indirect
|
github.com/pion/rtp v1.8.9 // indirect
|
||||||
github.com/pion/sctp v1.9.1 // indirect
|
github.com/pion/sctp v1.8.33 // indirect
|
||||||
github.com/pion/sdp/v3 v3.0.17 // indirect
|
github.com/pion/sdp/v3 v3.0.9 // indirect
|
||||||
github.com/pion/srtp/v3 v3.0.10 // indirect
|
github.com/pion/srtp/v3 v3.0.4 // indirect
|
||||||
github.com/pion/stun/v3 v3.1.1 // indirect
|
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||||
github.com/pion/turn/v4 v4.1.4 // indirect
|
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||||
github.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7 // indirect
|
github.com/pion/turn/v4 v4.0.0 // 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.8.1 // indirect
|
github.com/tidwall/btree v1.7.0 // 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.39.0 // indirect
|
go.opentelemetry.io/otel v1.32.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
go.opentelemetry.io/otel/metric v1.32.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
go.opentelemetry.io/otel/trace v1.32.0 // indirect
|
||||||
golang.org/x/crypto v0.46.0 // indirect
|
golang.org/x/crypto v0.29.0 // indirect
|
||||||
golang.org/x/mod v0.31.0 // indirect
|
golang.org/x/mod v0.22.0 // indirect
|
||||||
golang.org/x/net v0.48.0 // indirect
|
golang.org/x/net v0.31.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.28.0 // indirect
|
||||||
golang.org/x/text v0.33.0
|
golang.org/x/text v0.20.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.8.0 // indirect
|
||||||
golang.org/x/tools v0.40.0 // indirect
|
golang.org/x/tools v0.27.0 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
lukechampine.com/blake3 v1.4.1 // indirect
|
lukechampine.com/blake3 v1.3.0 // indirect
|
||||||
modernc.org/libc v1.67.4 // indirect
|
modernc.org/libc v1.61.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.8.0 // indirect
|
||||||
modernc.org/sqlite v1.43.0 // indirect
|
modernc.org/sqlite v1.33.1 // indirect
|
||||||
zombiezen.com/go/sqlite v1.4.2 // indirect
|
zombiezen.com/go/sqlite v1.4.0 // indirect
|
||||||
)
|
)
|
||||||
@@ -24,10 +24,8 @@ type DefaultFileController struct {
|
|||||||
|
|
||||||
func NewController() *Controller {
|
func NewController() *Controller {
|
||||||
return &Controller{
|
return &Controller{
|
||||||
GetConfig: func(v any) {},
|
GetConfig: func(v any) {},
|
||||||
GetProxy: func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error) {
|
GetProxy: func(requestProxy *base.RequestProxy) func(*http.Request) (*url.URL, error) { return nil },
|
||||||
return requestProxy.ToHandler()
|
|
||||||
},
|
|
||||||
FileController: &DefaultFileController{},
|
FileController: &DefaultFileController{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
package fetcher
|
package fetcher
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/internal/controller"
|
"github.com/GopeedLab/gopeed/internal/controller"
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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(req *base.Request, opts *base.Options) error
|
// Resolve resource info from request
|
||||||
|
Resolve(req *base.Request) error
|
||||||
|
// Create ready to download, but not started
|
||||||
|
Create(opts *base.Options) error
|
||||||
Start() error
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ type config struct {
|
|||||||
ListenPort int `json:"listenPort"`
|
ListenPort int `json:"listenPort"`
|
||||||
Trackers []string `json:"trackers"`
|
Trackers []string `json:"trackers"`
|
||||||
// SeedKeep is always keep seeding after downloading is complete, unless manually stopped.
|
// SeedKeep is always keep seeding after downloading is complete, unless manually stopped.
|
||||||
SeedKeep bool `json:"seedKeep"`
|
SeedKeep bool `json:"seedKeep`
|
||||||
// SeedRatio is the ratio of uploaded data to downloaded data to seed.
|
// SeedRatio is the ratio of uploaded data to downloaded data to seed.
|
||||||
SeedRatio float64 `json:"seedRatio"`
|
SeedRatio float64 `json:"seedRatio"`
|
||||||
// SeedTime is the time in seconds to seed after downloading is complete.
|
// SeedTime is the time in seconds to seed after downloading is complete.
|
||||||
|
|||||||
+59
-125
@@ -4,6 +4,13 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/GopeedLab/gopeed/internal/controller"
|
||||||
|
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/util"
|
||||||
|
"github.com/anacrolix/torrent"
|
||||||
|
"github.com/anacrolix/torrent/metainfo"
|
||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,23 +19,15 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/internal/controller"
|
|
||||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/util"
|
|
||||||
"github.com/anacrolix/torrent"
|
|
||||||
"github.com/anacrolix/torrent/metainfo"
|
|
||||||
"github.com/anacrolix/torrent/storage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cfg *torrent.ClientConfig
|
client *torrent.Client
|
||||||
client *torrent.Client
|
lock sync.Mutex
|
||||||
lock sync.Mutex
|
torrentDirMap = make(map[string]string)
|
||||||
closeCtx context.Context
|
ftMap = make(map[string]*fileTorrentImpl)
|
||||||
closeFunc func()
|
closeCtx context.Context
|
||||||
|
closeFunc func()
|
||||||
)
|
)
|
||||||
|
|
||||||
type Fetcher struct {
|
type Fetcher struct {
|
||||||
@@ -71,12 +70,21 @@ 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)
|
||||||
@@ -91,12 +99,11 @@ func (f *Fetcher) initClient() (err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
|
func (f *Fetcher) Resolve(req *base.Request) error {
|
||||||
f.meta.Req = req
|
if err := base.ParseReqExtra[bt.ReqExtra](req); err != nil {
|
||||||
f.meta.Opts = opts
|
return err
|
||||||
if f.meta.Opts == nil {
|
|
||||||
f.meta.Opts = &base.Options{}
|
|
||||||
}
|
}
|
||||||
|
f.meta.Req = req
|
||||||
if err := f.addTorrent(req, false); err != nil {
|
if err := f.addTorrent(req, false); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -104,13 +111,23 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) 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 {
|
||||||
@@ -130,12 +147,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.torrent.DisallowDataDownload()
|
f.torrentReady.Store(false)
|
||||||
|
f.safeDrop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,59 +249,6 @@ 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,
|
||||||
@@ -391,20 +355,12 @@ func (f *Fetcher) WaitUpload() (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
|
func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
|
||||||
if err = base.ParseReqExtra[bt.ReqExtra](req); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err = f.initClient(); err != nil {
|
if err = f.initClient(); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
schema := util.ParseSchema(req.URL)
|
schema := util.ParseSchema(req.URL)
|
||||||
privateTorrent := false
|
|
||||||
var spec *torrent.TorrentSpec
|
|
||||||
if schema == "MAGNET" {
|
if schema == "MAGNET" {
|
||||||
spec, err = torrent.TorrentSpecFromMagnetUri(req.URL)
|
f.torrent, err = client.AddMagnet(req.URL)
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
var reader io.Reader
|
var reader io.Reader
|
||||||
if schema == "FILE" {
|
if schema == "FILE" {
|
||||||
@@ -432,56 +388,34 @@ func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
|
|||||||
if err != nil && !strings.Contains(err.Error(), "expected EOF") {
|
if err != nil && !strings.Contains(err.Error(), "expected EOF") {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
f.torrent, err = client.AddTorrent(metaInfo)
|
||||||
info, er := metaInfo.UnmarshalInfo()
|
|
||||||
if er != nil {
|
|
||||||
return er
|
|
||||||
}
|
|
||||||
|
|
||||||
if info.Private != nil && *info.Private {
|
|
||||||
privateTorrent = true
|
|
||||||
}
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do not add external tracker to a private torrent.
|
// use map to deduplicate
|
||||||
if !privateTorrent {
|
trackers := make(map[string]bool)
|
||||||
// use map to deduplicate
|
if req.Extra != nil {
|
||||||
trackers := make(map[string]bool)
|
extra := req.Extra.(*bt.ReqExtra)
|
||||||
if req.Extra != nil {
|
if len(extra.Trackers) > 0 {
|
||||||
extra := req.Extra.(*bt.ReqExtra)
|
for _, tracker := range extra.Trackers {
|
||||||
if len(extra.Trackers) > 0 {
|
|
||||||
for _, tracker := range extra.Trackers {
|
|
||||||
trackers[tracker] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(f.config.Trackers) > 0 {
|
|
||||||
for _, tracker := range f.config.Trackers {
|
|
||||||
trackers[tracker] = true
|
trackers[tracker] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(trackers) > 0 {
|
}
|
||||||
announceList := make([][]string, 0)
|
if len(f.config.Trackers) > 0 {
|
||||||
for tracker := range trackers {
|
for _, tracker := range f.config.Trackers {
|
||||||
announceList = append(announceList, []string{tracker})
|
trackers[tracker] = true
|
||||||
}
|
|
||||||
f.torrent.AddTrackers(announceList)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(trackers) > 0 {
|
||||||
|
announceList := make([][]string, 0)
|
||||||
|
for tracker := range trackers {
|
||||||
|
announceList = append(announceList, []string{tracker})
|
||||||
|
}
|
||||||
|
f.torrent.AddTrackers(announceList)
|
||||||
|
}
|
||||||
<-f.torrent.GotInfo()
|
<-f.torrent.GotInfo()
|
||||||
f.torrentReady.Store(true)
|
f.torrentReady.Store(true)
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,17 @@ 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) {
|
||||||
@@ -31,7 +30,7 @@ func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
|
|||||||
dataUri := "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(buf)
|
dataUri := "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(buf)
|
||||||
err = fetcher.Resolve(&base.Request{
|
err = fetcher.Resolve(&base.Request{
|
||||||
URL: dataUri,
|
URL: dataUri,
|
||||||
}, nil)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -81,7 +80,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
|||||||
"udp://tracker.bitsearch.to:1337/announce",
|
"udp://tracker.bitsearch.to:1337/announce",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -111,7 +110,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
|||||||
"udp://tracker.bitsearch.to:1337/announce",
|
"udp://tracker.bitsearch.to:1337/announce",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, nil)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -145,7 +144,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
|||||||
t.Run("Resolve Unclean Torrent", func(t *testing.T) {
|
t.Run("Resolve Unclean Torrent", func(t *testing.T) {
|
||||||
err := fetcher.Resolve(&base.Request{
|
err := fetcher.Resolve(&base.Request{
|
||||||
URL: "./testdata/test.unclean.torrent",
|
URL: "./testdata/test.unclean.torrent",
|
||||||
}, nil)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Resolve Unclean Torrent Resolve() got = %v, want nil", err)
|
t.Errorf("Resolve Unclean Torrent Resolve() got = %v, want nil", err)
|
||||||
}
|
}
|
||||||
@@ -156,7 +155,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
|
|||||||
uri := "file:///" + file
|
uri := "file:///" + file
|
||||||
err := fetcher.Resolve(&base.Request{
|
err := fetcher.Resolve(&base.Request{
|
||||||
URL: uri,
|
URL: uri,
|
||||||
}, nil)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Resolve file scheme Torrent Resolve() got = %v, want nil", err)
|
t.Errorf("Resolve file scheme Torrent Resolve() got = %v, want nil", err)
|
||||||
}
|
}
|
||||||
@@ -239,130 +238,3 @@ 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))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
// 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.Name != metainfo.NoName {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
+410
-1565
File diff suppressed because it is too large.
Load diff
@@ -1,105 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/url"
|
|
||||||
"path"
|
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
|
||||||
fhttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Fetcher Data (for Store/Restore)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
type fetcherData struct {
|
|
||||||
Connections []*connection
|
|
||||||
RedirectURL string // Saved redirect URL for resume
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Fetcher Manager
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
type FetcherManager struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fm *FetcherManager) Name() string {
|
|
||||||
return "http"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fm *FetcherManager) Filters() []*fetcher.SchemeFilter {
|
|
||||||
return []*fetcher.SchemeFilter{
|
|
||||||
{
|
|
||||||
Type: fetcher.FilterTypeUrl,
|
|
||||||
Pattern: "HTTP",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Type: fetcher.FilterTypeUrl,
|
|
||||||
Pattern: "HTTPS",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fm *FetcherManager) Build() fetcher.Fetcher {
|
|
||||||
return &Fetcher{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fm *FetcherManager) ParseName(u string) string {
|
|
||||||
var name string
|
|
||||||
url, err := url.Parse(u)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
name = path.Base(url.Path)
|
|
||||||
if name == "" || name == "/" || name == "." {
|
|
||||||
name = url.Hostname()
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fm *FetcherManager) AutoRename() bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fm *FetcherManager) DefaultConfig() any {
|
|
||||||
return &config{
|
|
||||||
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36",
|
|
||||||
Connections: 16,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fm *FetcherManager) Store(f fetcher.Fetcher) (data any, err error) {
|
|
||||||
_f := f.(*Fetcher)
|
|
||||||
_f.redirectLock.Lock()
|
|
||||||
redirectURL := _f.redirectURL
|
|
||||||
_f.redirectLock.Unlock()
|
|
||||||
return &fetcherData{
|
|
||||||
Connections: _f.connections,
|
|
||||||
RedirectURL: redirectURL,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fm *FetcherManager) Restore() (v any, f func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher) {
|
|
||||||
return &fetcherData{}, func(meta *fetcher.FetcherMeta, v any) fetcher.Fetcher {
|
|
||||||
fd := v.(*fetcherData)
|
|
||||||
fb := &FetcherManager{}
|
|
||||||
fetcher := fb.Build().(*Fetcher)
|
|
||||||
fetcher.meta = meta
|
|
||||||
base.ParseReqExtra[fhttp.ReqExtra](fetcher.meta.Req)
|
|
||||||
base.ParseOptExtra[fhttp.OptsExtra](fetcher.meta.Opts)
|
|
||||||
if len(fd.Connections) > 0 {
|
|
||||||
fetcher.connections = fd.Connections
|
|
||||||
}
|
|
||||||
// Restore redirect URL for resume
|
|
||||||
if fd.RedirectURL != "" {
|
|
||||||
fetcher.redirectURL = fd.RedirectURL
|
|
||||||
}
|
|
||||||
return fetcher
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fm *FetcherManager) Close() error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,138 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,474 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
+62
-621
@@ -6,6 +6,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
"github.com/armon/go-socks5"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -15,10 +17,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
|
||||||
"github.com/armon/go-socks5"
|
|
||||||
"golang.org/x/text/encoding/simplifiedchinese"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -36,10 +34,6 @@ const (
|
|||||||
DownloadRename = "download (1).data"
|
DownloadRename = "download (1).data"
|
||||||
DownloadFile = Dir + DownloadName
|
DownloadFile = Dir + DownloadName
|
||||||
DownloadRenameFile = Dir + DownloadRename
|
DownloadRenameFile = Dir + DownloadRename
|
||||||
|
|
||||||
// TestChineseFileName is a common test filename with Chinese characters
|
|
||||||
// Used to test Content-Disposition parsing with various encodings
|
|
||||||
TestChineseFileName = "测试.zip"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func StartTestFileServer() net.Listener {
|
func StartTestFileServer() net.Listener {
|
||||||
@@ -70,9 +64,6 @@ func StartTestSlowFileServer(delay time.Duration) net.Listener {
|
|||||||
func StartTestCustomServer() net.Listener {
|
func StartTestCustomServer() net.Listener {
|
||||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
writer.WriteHeader(200)
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||||
file, err := os.Open(BuildFile)
|
file, err := os.Open(BuildFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -111,142 +102,6 @@ func StartTestCustomServer() net.Listener {
|
|||||||
defer file.Close()
|
defer file.Close()
|
||||||
io.Copy(writer, file)
|
io.Copy(writer, file)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/no-encode", func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
writer.Header().Set("Content-Disposition", "attachment; filename="+TestChineseFileName)
|
|
||||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
|
||||||
file, err := os.Open(BuildFile)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
io.Copy(writer, file)
|
|
||||||
})
|
|
||||||
// Test endpoint for mixed encoding: filename= with garbled characters (including special chars)
|
|
||||||
// and filename*= with proper UTF-8. This tests the case where mime.ParseMediaType fails
|
|
||||||
// due to invalid characters like <a> tags in the filename.
|
|
||||||
mux.HandleFunc("/mixed-encoding", func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
// This simulates a server that sends a garbled filename= with special chars that cause
|
|
||||||
// mime.ParseMediaType to fail, plus a proper filename*=UTF-8''...
|
|
||||||
// The filename*= should be preferred and correctly parsed.
|
|
||||||
writer.Header().Set("Content-Disposition", `attachment;filename="garbled<invalid>chars.zip";filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)
|
|
||||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
|
||||||
file, err := os.Open(BuildFile)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
io.Copy(writer, file)
|
|
||||||
})
|
|
||||||
// Test endpoint for filename*= only (RFC 5987 format)
|
|
||||||
mux.HandleFunc("/filename-star", func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
// URL-encoded TestChineseFileName: 测试.zip -> %E6%B5%8B%E8%AF%95.zip
|
|
||||||
writer.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''%E6%B5%8B%E8%AF%95.zip`)
|
|
||||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
|
||||||
file, err := os.Open(BuildFile)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
io.Copy(writer, file)
|
|
||||||
})
|
|
||||||
// Test endpoint for GBK-encoded filename (common on Chinese Windows servers)
|
|
||||||
// This simulates the case where Chinese characters are sent as GBK bytes
|
|
||||||
// which appear as garbled characters when interpreted as UTF-8.
|
|
||||||
// For example, "测试" in GBK is [B2 E2 CA D4] which is invalid UTF-8.
|
|
||||||
// Our fix detects invalid UTF-8 and attempts GBK decoding.
|
|
||||||
mux.HandleFunc("/gbk-encoded", func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
// Encode TestChineseFileName as GBK
|
|
||||||
gbkEncoder := simplifiedchinese.GBK.NewEncoder()
|
|
||||||
gbkBytes, _ := gbkEncoder.Bytes([]byte(TestChineseFileName))
|
|
||||||
// Send GBK bytes directly in filename (simulating broken server behavior)
|
|
||||||
writer.Header().Set("Content-Disposition", `attachment; filename="`+string(gbkBytes)+`"`)
|
|
||||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
|
||||||
file, err := os.Open(BuildFile)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
io.Copy(writer, file)
|
|
||||||
})
|
|
||||||
// Test endpoint for filenames with plus signs (C++ files, etc.)
|
|
||||||
// This tests that %2B decodes to + not space
|
|
||||||
mux.HandleFunc("/plus-sign-encoded", func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
// Use filename*= format with %2B encoding for plus signs
|
|
||||||
writer.Header().Set("Content-Disposition", `attachment; filename*=UTF-8''C%2B%2B%20%20Primer%20%20Plus.mobi`)
|
|
||||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
|
||||||
file, err := os.Open(BuildFile)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
io.Copy(writer, file)
|
|
||||||
})
|
|
||||||
// Test endpoint for plus sign in URL path
|
|
||||||
mux.HandleFunc("/C%2B%2B%20Primer.txt", func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
file, err := os.Open(BuildFile)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
io.Copy(writer, file)
|
|
||||||
})
|
|
||||||
// Test endpoint for filename with HTML-encoded ampersand (&)
|
|
||||||
// This tests the case from the bug report where filenames containing & are
|
|
||||||
// HTML-encoded as & by the server, causing truncation at the semicolon.
|
|
||||||
// Example: "查询处理&优化.pptx" -> "查询处理&优化.pptx"
|
|
||||||
mux.HandleFunc("/ampersand-encoded", func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
// Simulate server sending filename with HTML-encoded ampersand
|
|
||||||
writer.Header().Set("Content-Disposition", `attachment; filename="查询处理&优化.pptx"`)
|
|
||||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
|
||||||
file, err := os.Open(BuildFile)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
io.Copy(writer, file)
|
|
||||||
})
|
|
||||||
// Test endpoint for unquoted filename with HTML-encoded ampersand
|
|
||||||
// Some servers might send unquoted filenames with HTML entities
|
|
||||||
mux.HandleFunc("/ampersand-unquoted", func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
writer.Header().Set("Content-Disposition", `attachment; filename=test&file.txt`)
|
|
||||||
writer.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
|
||||||
file, err := os.Open(BuildFile)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
io.Copy(writer, file)
|
|
||||||
})
|
|
||||||
// Test 403 Forbidden endpoint
|
|
||||||
mux.HandleFunc("/forbidden", func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
writer.WriteHeader(403)
|
|
||||||
})
|
|
||||||
return mux
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartTestHostHeaderServer starts a server that validates the Host header
|
|
||||||
// Returns 400 Bad Request if the Host header value equals "test"
|
|
||||||
func StartTestHostHeaderServer() net.Listener {
|
|
||||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
// If the Host header is "test", return 400 (simulating server that validates Host)
|
|
||||||
if request.Host == "test" {
|
|
||||||
writer.WriteHeader(400)
|
|
||||||
writer.Write([]byte("Bad Request: Invalid Host header"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writer.WriteHeader(200)
|
|
||||||
writer.Write([]byte("OK"))
|
|
||||||
})
|
|
||||||
return mux
|
return mux
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -296,161 +151,23 @@ func StartTestPostServer() net.Listener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func StartTestErrorServer() net.Listener {
|
func StartTestErrorServer() net.Listener {
|
||||||
|
counter := 0
|
||||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
||||||
// Always return 404 error to test error handling
|
counter++
|
||||||
writer.WriteHeader(404)
|
if counter != 1 {
|
||||||
return
|
writer.WriteHeader(500)
|
||||||
|
return
|
||||||
|
}
|
||||||
})
|
})
|
||||||
return mux
|
return mux
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartTestOneTimeServer creates a server where the URL can only be downloaded once
|
|
||||||
// The first non-probe request succeeds, all subsequent requests return 404
|
|
||||||
// This simulates one-time download URLs (e.g., signed URLs that expire after first use)
|
|
||||||
func StartTestOneTimeServer() net.Listener {
|
|
||||||
var accessed atomic.Bool
|
|
||||||
|
|
||||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
// First full download request succeeds, subsequent requests fail
|
|
||||||
if accessed.Swap(true) {
|
|
||||||
writer.WriteHeader(404)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// First request - return full file without Range support
|
|
||||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
|
||||||
file, err := os.Open(BuildFile)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
io.Copy(writer, file)
|
|
||||||
})
|
|
||||||
return mux
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartTestExpiringRedirectServer creates a server that simulates expiring redirect URLs.
|
|
||||||
// The original URL redirects to a temporary URL that expires after a specified number of requests.
|
|
||||||
// When the temporary URL expires (returns 403), the client should retry with the original URL
|
|
||||||
// to get a new redirect URL.
|
|
||||||
// Parameters:
|
|
||||||
// - requestsBeforeExpire: number of requests the temporary URL accepts before expiring
|
|
||||||
// - delayPerByte: optional delay per byte for slow transfer (use 0 for no delay)
|
|
||||||
func StartTestExpiringRedirectServer(requestsBeforeExpire int32, delayPerByte time.Duration) net.Listener {
|
|
||||||
var redirectVersion atomic.Int32
|
|
||||||
var requestCount atomic.Int32
|
|
||||||
redirectVersion.Store(1)
|
|
||||||
|
|
||||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
|
||||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
path := request.URL.Path
|
|
||||||
|
|
||||||
// Original URL - redirects to current version of temporary URL
|
|
||||||
if path == "/"+BuildName {
|
|
||||||
// Reset request count when original URL is accessed
|
|
||||||
requestCount.Store(0)
|
|
||||||
// Redirect to versioned temporary URL
|
|
||||||
version := redirectVersion.Load()
|
|
||||||
redirectURL := fmt.Sprintf("/redirect-v%d/%s", version, BuildName)
|
|
||||||
http.Redirect(writer, request, redirectURL, http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Temporary URL handler - matches /redirect-v{N}/... pattern
|
|
||||||
if strings.HasPrefix(path, "/redirect-v") {
|
|
||||||
// Check if the redirect has expired
|
|
||||||
count := requestCount.Add(1)
|
|
||||||
if count > requestsBeforeExpire {
|
|
||||||
// Redirect expired - increment version for next redirect
|
|
||||||
redirectVersion.Add(1)
|
|
||||||
writer.WriteHeader(403)
|
|
||||||
writer.Write([]byte("Redirect URL expired"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serve the file with range support
|
|
||||||
rangeFileHandle(
|
|
||||||
writer,
|
|
||||||
request,
|
|
||||||
nil,
|
|
||||||
func(file *os.File, n int64) {
|
|
||||||
if delayPerByte > 0 {
|
|
||||||
slowCopyNWithDelay(sl, writer, file, n, delayPerByte)
|
|
||||||
} else {
|
|
||||||
io.CopyN(writer, file, n)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not found
|
|
||||||
writer.WriteHeader(404)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartTestSlowStartServer creates a server with configurable delay per request
|
|
||||||
// This allows testing slow-start connection expansion to reach max connections
|
|
||||||
func StartTestSlowStartServer(delayPerByte time.Duration) net.Listener {
|
|
||||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
rangeFileHandle(
|
|
||||||
writer,
|
|
||||||
request,
|
|
||||||
nil,
|
|
||||||
func(file *os.File, n int64) {
|
|
||||||
slowCopyNWithDelay(sl, writer, file, n, delayPerByte)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return mux
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// slowCopyNWithDelay copies n bytes from src to dst with a delay per byte
|
|
||||||
func slowCopyNWithDelay(sl *shutdownListener, dst io.Writer, src io.Reader, n int64, delayPerByte time.Duration) {
|
|
||||||
buf := make([]byte, 32*1024)
|
|
||||||
remaining := n
|
|
||||||
for remaining > 0 {
|
|
||||||
if sl.isShutdown {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
toRead := int64(len(buf))
|
|
||||||
if toRead > remaining {
|
|
||||||
toRead = remaining
|
|
||||||
}
|
|
||||||
nr, er := src.Read(buf[:toRead])
|
|
||||||
if nr > 0 {
|
|
||||||
nw, ew := dst.Write(buf[0:nr])
|
|
||||||
if nw > 0 {
|
|
||||||
remaining -= int64(nw)
|
|
||||||
// Add delay based on bytes written
|
|
||||||
if delayPerByte > 0 {
|
|
||||||
time.Sleep(delayPerByte * time.Duration(nw))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ew != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if er != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartTestLimitServer connections limit server
|
// StartTestLimitServer connections limit server
|
||||||
func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
|
func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
|
||||||
var connections atomic.Int32
|
var connections atomic.Int32
|
||||||
var slowOnce atomic.Bool
|
|
||||||
|
|
||||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@@ -464,251 +181,67 @@ func StartTestLimitServer(maxConnections int32, delay int64) net.Listener {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// First request intentionally delays the first write to trigger a single read timeout,
|
r := request.Header.Get("Range")
|
||||||
// subsequent requests respond at normal speed.
|
if r == "" {
|
||||||
useInitialDelay := delay > 0 && !slowOnce.Swap(true)
|
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
||||||
rangeFileHandle(
|
writer.WriteHeader(200)
|
||||||
writer,
|
(writer.(http.Flusher)).Flush()
|
||||||
request,
|
|
||||||
nil,
|
|
||||||
func(file *os.File, n int64) {
|
|
||||||
if useInitialDelay {
|
|
||||||
slowCopyAfterDelay(sl, writer, file, n, delay)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slowCopyN(sl, writer, file, n, 0)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return mux
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartTestTimeoutOnceServer creates a server that times out on first request, then works normally
|
file, err := os.Open(BuildFile)
|
||||||
func StartTestTimeoutOnceServer(delay int64) net.Listener {
|
if err != nil {
|
||||||
var timeoutOnce atomic.Bool
|
panic(err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
slowCopy(sl, writer, file, delay)
|
||||||
|
} else {
|
||||||
|
// split range
|
||||||
|
s := strings.Split(r, "=")
|
||||||
|
if len(s) != 2 {
|
||||||
|
writer.WriteHeader(400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s = strings.Split(s[1], "-")
|
||||||
|
if len(s) != 2 {
|
||||||
|
writer.WriteHeader(400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start, err := strconv.ParseInt(s[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writer.WriteHeader(400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
end, err := strconv.ParseInt(s[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writer.WriteHeader(400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if start < 0 || end < 0 || start > end {
|
||||||
|
writer.WriteHeader(400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if end >= BuildSize {
|
||||||
|
end = BuildSize - 1
|
||||||
|
}
|
||||||
|
writer.Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1))
|
||||||
|
writer.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, BuildSize))
|
||||||
|
writer.Header().Set("Accept-Ranges", "bytes")
|
||||||
|
writer.WriteHeader(206)
|
||||||
|
(writer.(http.Flusher)).Flush()
|
||||||
|
|
||||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
file, err := os.Open(BuildFile)
|
||||||
mux := http.NewServeMux()
|
if err != nil {
|
||||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
writer.WriteHeader(500)
|
||||||
// First request delays to trigger timeout, subsequent requests respond normally
|
return
|
||||||
useInitialDelay := delay > 0 && !timeoutOnce.Swap(true)
|
}
|
||||||
rangeFileHandle(
|
defer file.Close()
|
||||||
writer,
|
file.Seek(start, 0)
|
||||||
request,
|
slowCopyN(sl, writer, file, end-start+1, delay)
|
||||||
nil,
|
|
||||||
func(file *os.File, n int64) {
|
|
||||||
if useInitialDelay {
|
|
||||||
slowCopyAfterDelay(sl, writer, file, n, delay)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slowCopyN(sl, writer, file, n, 0)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return mux
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartTestTemporary500Server creates a server that returns 500 for a duration, then recovers
|
|
||||||
// Uses slow transfer to ensure the file isn't fully downloaded during resolve phase
|
|
||||||
func StartTestTemporary500Server(errorDuration time.Duration) net.Listener {
|
|
||||||
startTime := time.Now()
|
|
||||||
var requestCount atomic.Int32
|
|
||||||
|
|
||||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
reqNum := requestCount.Add(1)
|
|
||||||
|
|
||||||
// First request (Resolve) succeeds with slow transfer to prevent full download
|
|
||||||
// Subsequent requests return 500 for the specified duration, then recover
|
|
||||||
if reqNum == 1 {
|
|
||||||
// Slow transfer for resolve: 50 microsecond per byte (~20MB/s)
|
|
||||||
// This ensures resolve doesn't complete the full 200MB file
|
|
||||||
rangeFileHandle(
|
|
||||||
writer,
|
|
||||||
request,
|
|
||||||
nil,
|
|
||||||
func(file *os.File, n int64) {
|
|
||||||
slowCopyNWithDelay(sl, writer, file, n, 50*time.Microsecond)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subsequent requests: return 500 for errorDuration, then normal
|
|
||||||
if time.Since(startTime) < errorDuration {
|
|
||||||
writer.WriteHeader(500)
|
|
||||||
writer.Write([]byte("Internal Server Error"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rangeFileHandle(
|
|
||||||
writer,
|
|
||||||
request,
|
|
||||||
nil,
|
|
||||||
func(file *os.File, n int64) {
|
|
||||||
slowCopyN(sl, writer, file, n, 0)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
return mux
|
return mux
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartTestFailThenRecoverServer creates a server that fails all connections initially,
|
|
||||||
// then recovers after a specified number of failed requests.
|
|
||||||
// This tests the retry functionality when calling Start() again after a download fails.
|
|
||||||
// Parameters:
|
|
||||||
// - failedRequestsBeforeRecover: number of requests that will fail before server recovers
|
|
||||||
//
|
|
||||||
// Note: Uses 416 (Range Not Satisfiable) instead of 500 because 5xx errors are exempt
|
|
||||||
// from failure counting and will retry indefinitely. 416 is counted as a failure.
|
|
||||||
func StartTestFailThenRecoverServer(failedRequestsBeforeRecover int32) net.Listener {
|
|
||||||
var requestCount atomic.Int32
|
|
||||||
|
|
||||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
reqNum := requestCount.Add(1)
|
|
||||||
|
|
||||||
// First request (Resolve) always succeeds
|
|
||||||
if reqNum == 1 {
|
|
||||||
rangeFileHandle(
|
|
||||||
writer,
|
|
||||||
request,
|
|
||||||
nil,
|
|
||||||
func(file *os.File, n int64) {
|
|
||||||
io.CopyN(writer, file, n)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subsequent requests fail until we've had enough failures
|
|
||||||
// Use 416 Range Not Satisfiable - this error is counted towards failure limit
|
|
||||||
if reqNum <= failedRequestsBeforeRecover+1 { // +1 because first request is resolve
|
|
||||||
writer.WriteHeader(416)
|
|
||||||
writer.Write([]byte("Range Not Satisfiable"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// After enough failures, server recovers
|
|
||||||
rangeFileHandle(
|
|
||||||
writer,
|
|
||||||
request,
|
|
||||||
nil,
|
|
||||||
func(file *os.File, n int64) {
|
|
||||||
io.CopyN(writer, file, n)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return mux
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartTestRangeBugServer simulate bug server:
|
|
||||||
// Don't follow Range request rules, always return more data than range, e.g. Range: bytes=0-100, return 150 bytes
|
|
||||||
func StartTestRangeBugServer() net.Listener {
|
|
||||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
rangeFileHandle(
|
|
||||||
writer,
|
|
||||||
request,
|
|
||||||
func(end int64) int64 {
|
|
||||||
var bugEnd = end
|
|
||||||
if end != 0 {
|
|
||||||
bugEnd = end + 50
|
|
||||||
if bugEnd >= BuildSize {
|
|
||||||
bugEnd = end
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bugEnd
|
|
||||||
},
|
|
||||||
func(file *os.File, n int64) {
|
|
||||||
io.CopyN(writer, file, n)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return mux
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func rangeFileHandle(writer http.ResponseWriter, request *http.Request, modifyEnd func(end int64) int64, iocpN func(file *os.File, n int64)) {
|
|
||||||
r := request.Header.Get("Range")
|
|
||||||
|
|
||||||
// If no Range header, return full file with Accept-Ranges header
|
|
||||||
if r == "" {
|
|
||||||
// Open file first to ensure it exists
|
|
||||||
file, err := os.Open(BuildFile)
|
|
||||||
if err != nil {
|
|
||||||
writer.WriteHeader(500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", BuildSize))
|
|
||||||
writer.Header().Set("Accept-Ranges", "bytes")
|
|
||||||
writer.WriteHeader(200)
|
|
||||||
(writer.(http.Flusher)).Flush()
|
|
||||||
|
|
||||||
iocpN(file, BuildSize)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// split range
|
|
||||||
s := strings.Split(r, "=")
|
|
||||||
if len(s) != 2 {
|
|
||||||
writer.WriteHeader(400)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s = strings.Split(s[1], "-")
|
|
||||||
if len(s) != 2 {
|
|
||||||
writer.WriteHeader(400)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
start, err := strconv.ParseInt(s[0], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
writer.WriteHeader(400)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
end, err := strconv.ParseInt(s[1], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
writer.WriteHeader(400)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if start < 0 || end < 0 || start > end {
|
|
||||||
writer.WriteHeader(400)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if end >= BuildSize {
|
|
||||||
end = BuildSize - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if modifyEnd != nil {
|
|
||||||
end = modifyEnd(end)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open file before sending headers to ensure it exists
|
|
||||||
file, err := os.Open(BuildFile)
|
|
||||||
if err != nil {
|
|
||||||
writer.WriteHeader(500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
file.Seek(start, 0)
|
|
||||||
|
|
||||||
writer.Header().Set("Content-Length", fmt.Sprintf("%d", end-start+1))
|
|
||||||
writer.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, BuildSize))
|
|
||||||
writer.Header().Set("Accept-Ranges", "bytes")
|
|
||||||
writer.WriteHeader(206)
|
|
||||||
(writer.(http.Flusher)).Flush()
|
|
||||||
|
|
||||||
iocpN(file, end-start+1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// slowCopyN copies n bytes from src to dst, speed limit is bytes per second
|
// slowCopyN copies n bytes from src to dst, speed limit is bytes per second
|
||||||
func slowCopy(sl *shutdownListener, dst io.Writer, src io.Reader, delay int64) (written int64, err error) {
|
func slowCopy(sl *shutdownListener, dst io.Writer, src io.Reader, delay int64) (written int64, err error) {
|
||||||
buf := make([]byte, 32*1024)
|
buf := make([]byte, 32*1024)
|
||||||
@@ -756,14 +289,6 @@ func slowCopyN(sl *shutdownListener, dst io.Writer, src io.Reader, n int64, dela
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// slowCopyAfterDelay sleeps once before performing a normal copy to simulate a single timeout.
|
|
||||||
func slowCopyAfterDelay(sl *shutdownListener, dst io.Writer, src io.Reader, n int64, delay int64) (written int64, err error) {
|
|
||||||
if delay > 0 {
|
|
||||||
time.Sleep(time.Millisecond * time.Duration(delay))
|
|
||||||
}
|
|
||||||
return slowCopyN(sl, dst, src, n, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func startTestServer(serverHandle func(sl *shutdownListener) http.Handler) net.Listener {
|
func startTestServer(serverHandle func(sl *shutdownListener) http.Handler) net.Listener {
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -808,9 +333,8 @@ type shutdownListener struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *shutdownListener) Close() error {
|
func (c *shutdownListener) Close() error {
|
||||||
// Shutdown server first (waits for in-flight requests), then set isShutdown
|
|
||||||
closeErr := c.server.Shutdown(context.Background())
|
|
||||||
c.isShutdown = true
|
c.isShutdown = true
|
||||||
|
closeErr := c.server.Shutdown(context.Background())
|
||||||
if err := ifExistAndRemove(BuildFile); err != nil {
|
if err := ifExistAndRemove(BuildFile); err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
}
|
}
|
||||||
@@ -851,89 +375,6 @@ func StartSocks5Server(usr, pwd string) net.Listener {
|
|||||||
return listener
|
return listener
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartTestPatchURLServer creates a server with two endpoints:
|
|
||||||
// - /bad-url: always returns 404 (simulates a broken download link)
|
|
||||||
// - /good-url: returns the file successfully (simulates a working download link)
|
|
||||||
// This is used to test the Patch functionality where a failed download URL can be
|
|
||||||
// replaced with a working one.
|
|
||||||
func StartTestPatchURLServer() net.Listener {
|
|
||||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
// Bad URL - always fails with 404
|
|
||||||
mux.HandleFunc("/bad-url", func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
writer.WriteHeader(404)
|
|
||||||
writer.Write([]byte("Not Found"))
|
|
||||||
})
|
|
||||||
// Good URL - returns the file successfully with range support
|
|
||||||
mux.HandleFunc("/good-url", func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
rangeFileHandle(
|
|
||||||
writer,
|
|
||||||
request,
|
|
||||||
nil,
|
|
||||||
func(file *os.File, n int64) {
|
|
||||||
io.CopyN(writer, file, n)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return mux
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartTestCookieExpiringServer creates a server that simulates cookie expiration during download.
|
|
||||||
// - First request (Resolve): accepts "session=old_token" and succeeds
|
|
||||||
// - Subsequent requests: "session=old_token" is expired (returns 401), only "session=new_token" works
|
|
||||||
// This is used to test the Patch functionality where a cookie expires mid-download,
|
|
||||||
// requiring the user to patch with a new cookie to resume.
|
|
||||||
func StartTestCookieExpiringServer() net.Listener {
|
|
||||||
var requestCount atomic.Int32
|
|
||||||
|
|
||||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/"+BuildName, func(writer http.ResponseWriter, request *http.Request) {
|
|
||||||
reqNum := requestCount.Add(1)
|
|
||||||
cookie := request.Header.Get("Cookie")
|
|
||||||
|
|
||||||
// First request (Resolve): accept old_token
|
|
||||||
if reqNum == 1 {
|
|
||||||
if !strings.Contains(cookie, "session=old_token") {
|
|
||||||
writer.WriteHeader(401)
|
|
||||||
writer.Write([]byte("Unauthorized: Invalid cookie"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Return file info for resolve (with range support)
|
|
||||||
rangeFileHandle(
|
|
||||||
writer,
|
|
||||||
request,
|
|
||||||
nil,
|
|
||||||
func(file *os.File, n int64) {
|
|
||||||
io.CopyN(writer, file, n)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subsequent requests: old_token is expired, only new_token works
|
|
||||||
if strings.Contains(cookie, "session=new_token") {
|
|
||||||
// New token is valid - return the file with range support
|
|
||||||
rangeFileHandle(
|
|
||||||
writer,
|
|
||||||
request,
|
|
||||||
nil,
|
|
||||||
func(file *os.File, n int64) {
|
|
||||||
io.CopyN(writer, file, n)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Old token or invalid token - return 401
|
|
||||||
writer.WriteHeader(401)
|
|
||||||
writer.Write([]byte("Unauthorized: Cookie expired"))
|
|
||||||
})
|
|
||||||
return mux
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func AssertResourceEqual(want, got *base.Resource) bool {
|
func AssertResourceEqual(want, got *base.Resource) bool {
|
||||||
// Ignore ctime
|
// Ignore ctime
|
||||||
if got != nil && len(got.Files) > 0 {
|
if got != nil && len(got.Files) > 0 {
|
||||||
|
|||||||
+11
-81
@@ -2,14 +2,13 @@ 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
|
||||||
@@ -78,7 +77,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 len(r.Files) == 0 {
|
if r.Files == nil || 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 {
|
||||||
@@ -124,7 +123,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 := range fileSize {
|
for i := 0; i < fileSize; i++ {
|
||||||
o.SelectFiles[i] = i
|
o.SelectFiles[i] = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,7 +148,7 @@ func ParseReqExtra[E any](req *Request) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseOptExtra[E any](opts *Options) error {
|
func ParseOptsExtra[E any](opts *Options) error {
|
||||||
if opts.Extra == nil {
|
if opts.Extra == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -164,30 +163,15 @@ func ParseOptExtra[E any](opts *Options) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateTaskBatch struct {
|
|
||||||
Reqs []*CreateTaskBatchItem `json:"reqs"`
|
|
||||||
Opts *Options `json:"opts"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateTaskBatchItem struct {
|
|
||||||
Req *Request `json:"req"`
|
|
||||||
Opts *Options `json:"opts"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DownloaderStoreConfig is the config that can restore the downloader.
|
// DownloaderStoreConfig is the config that can restore the downloader.
|
||||||
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 {
|
||||||
@@ -200,24 +184,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,45 +206,9 @@ 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
|
||||||
|
|||||||
+1
-174
@@ -18,16 +18,6 @@ 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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -39,16 +29,6 @@ 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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -63,17 +43,7 @@ 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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -89,64 +59,6 @@ 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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -159,10 +71,6 @@ 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)
|
||||||
@@ -337,84 +245,6 @@ 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) {
|
||||||
@@ -425,9 +255,6 @@ 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)
|
||||||
|
|||||||
+82
-643
@@ -2,7 +2,15 @@ package download
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"github.com/GopeedLab/gopeed/internal/controller"
|
||||||
|
"github.com/GopeedLab/gopeed/internal/fetcher"
|
||||||
|
"github.com/GopeedLab/gopeed/internal/logger"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
||||||
|
"github.com/GopeedLab/gopeed/pkg/util"
|
||||||
|
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/pkgerrors"
|
||||||
"math"
|
"math"
|
||||||
gohttp "net/http"
|
gohttp "net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -14,16 +22,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/internal/controller"
|
|
||||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
|
||||||
"github.com/GopeedLab/gopeed/internal/logger"
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/protocol/http"
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/util"
|
|
||||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
|
||||||
"github.com/rs/zerolog"
|
|
||||||
"github.com/rs/zerolog/pkgerrors"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -46,24 +44,6 @@ var (
|
|||||||
|
|
||||||
type Listener func(event *Event)
|
type Listener func(event *Event)
|
||||||
|
|
||||||
// ExtractStatus represents the current status of archive extraction
|
|
||||||
type ExtractStatus string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// ExtractStatusNone indicates extraction has not started
|
|
||||||
ExtractStatusNone ExtractStatus = ""
|
|
||||||
// ExtractStatusQueued indicates extraction is waiting in the queue
|
|
||||||
ExtractStatusQueued ExtractStatus = "queued"
|
|
||||||
// ExtractStatusWaitingParts indicates waiting for other multi-part archive parts to complete
|
|
||||||
ExtractStatusWaitingParts ExtractStatus = "waitingParts"
|
|
||||||
// ExtractStatusExtracting indicates extraction is in progress
|
|
||||||
ExtractStatusExtracting ExtractStatus = "extracting"
|
|
||||||
// ExtractStatusDone indicates extraction completed successfully
|
|
||||||
ExtractStatusDone ExtractStatus = "done"
|
|
||||||
// ExtractStatusError indicates extraction failed
|
|
||||||
ExtractStatusError ExtractStatus = "error"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Progress struct {
|
type Progress struct {
|
||||||
// Total download time(ns)
|
// Total download time(ns)
|
||||||
Used int64 `json:"used"`
|
Used int64 `json:"used"`
|
||||||
@@ -75,16 +55,6 @@ type Progress struct {
|
|||||||
UploadSpeed int64 `json:"uploadSpeed"`
|
UploadSpeed int64 `json:"uploadSpeed"`
|
||||||
// Uploaded size(bytes)
|
// Uploaded size(bytes)
|
||||||
Uploaded int64 `json:"uploaded"`
|
Uploaded int64 `json:"uploaded"`
|
||||||
// ExtractStatus indicates the current status of archive extraction
|
|
||||||
ExtractStatus ExtractStatus `json:"extractStatus"`
|
|
||||||
// ExtractProgress is the percentage of extraction completed (0-100)
|
|
||||||
ExtractProgress int `json:"extractProgress"`
|
|
||||||
// MultiPartBaseName is set for multi-part archives to group related parts
|
|
||||||
MultiPartBaseName string `json:"multiPartBaseName,omitempty"`
|
|
||||||
// MultiPartNumber is the part number for multi-part archives (1-indexed)
|
|
||||||
MultiPartNumber int `json:"multiPartNumber,omitempty"`
|
|
||||||
// MultiPartIsFirst indicates if this is the first part of a multi-part archive
|
|
||||||
MultiPartIsFirst bool `json:"multiPartIsFirst,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Downloader struct {
|
type Downloader struct {
|
||||||
@@ -104,10 +74,6 @@ type Downloader struct {
|
|||||||
checkDuplicateLock *sync.Mutex
|
checkDuplicateLock *sync.Mutex
|
||||||
closed atomic.Bool
|
closed atomic.Bool
|
||||||
|
|
||||||
// claimedExtractions tracks which multi-part archives have been claimed for extraction
|
|
||||||
// Key: fullBaseName (e.g., "/path/archive.7z"), Value: taskID that claimed it
|
|
||||||
claimedExtractions sync.Map
|
|
||||||
|
|
||||||
extensions []*Extension
|
extensions []*Extension
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +148,6 @@ func (d *Downloader) Setup() error {
|
|||||||
tasks = append(tasks[:i], tasks[i+1:]...)
|
tasks = append(tasks[:i], tasks[i+1:]...)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
d.assignFetcherManager(task)
|
|
||||||
initTask(task)
|
initTask(task)
|
||||||
if task.Status != base.DownloadStatusDone && task.Status != base.DownloadStatusError {
|
if task.Status != base.DownloadStatusDone && task.Status != base.DownloadStatusError {
|
||||||
task.Status = base.DownloadStatusPause
|
task.Status = base.DownloadStatusPause
|
||||||
@@ -205,9 +170,6 @@ func (d *Downloader) Setup() error {
|
|||||||
}
|
}
|
||||||
d.extensions = extensions
|
d.extensions = extensions
|
||||||
|
|
||||||
// Auto-cleanup non-existing tasks on startup
|
|
||||||
d.cleanupNonExistingTasks()
|
|
||||||
|
|
||||||
// handle upload
|
// handle upload
|
||||||
go func() {
|
go func() {
|
||||||
for _, task := range d.tasks {
|
for _, task := range d.tasks {
|
||||||
@@ -246,7 +208,7 @@ func (d *Downloader) Setup() error {
|
|||||||
if task.Status == base.DownloadStatusRunning {
|
if task.Status == base.DownloadStatusRunning {
|
||||||
downloadDataChanged = current != task.Progress.Downloaded
|
downloadDataChanged = current != task.Progress.Downloaded
|
||||||
task.Progress.Used = task.timer.Used()
|
task.Progress.Used = task.timer.Used()
|
||||||
task.Progress.Speed = task.updateSpeed(current-task.Progress.Downloaded, tick)
|
task.Progress.Speed = task.calcSpeed(task.speedArr, current-task.Progress.Downloaded, tick)
|
||||||
task.Progress.Downloaded = current
|
task.Progress.Downloaded = current
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +217,7 @@ func (d *Downloader) Setup() error {
|
|||||||
uploader := task.fetcher.(fetcher.Uploader)
|
uploader := task.fetcher.(fetcher.Uploader)
|
||||||
currentUploaded := uploader.UploadedBytes()
|
currentUploaded := uploader.UploadedBytes()
|
||||||
uploadDataChanged = currentUploaded != task.Progress.Uploaded
|
uploadDataChanged = currentUploaded != task.Progress.Uploaded
|
||||||
task.Progress.UploadSpeed = task.updateUploadSpeed(currentUploaded-task.Progress.Uploaded, tick)
|
task.Progress.UploadSpeed = task.calcSpeed(task.uploadSpeedArr, currentUploaded-task.Progress.Uploaded, tick)
|
||||||
task.Progress.Uploaded = currentUploaded
|
task.Progress.Uploaded = currentUploaded
|
||||||
}
|
}
|
||||||
d.emit(EventKeyProgress, task)
|
d.emit(EventKeyProgress, task)
|
||||||
@@ -274,51 +236,6 @@ func (d *Downloader) Setup() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupNonExistingTasks checks for tasks whose files are missing on disk
|
|
||||||
// and removes them if the AutoCleanMissingFiles config is enabled.
|
|
||||||
func (d *Downloader) cleanupNonExistingTasks() {
|
|
||||||
cfg, err := d.GetConfig()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the feature is disabled, do nothing
|
|
||||||
if !cfg.AutoDeleteMissingFileTasks {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var tasksToDelete []string
|
|
||||||
|
|
||||||
for _, task := range d.tasks {
|
|
||||||
if task.Meta == nil || task.Meta.Res == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var targetPath string
|
|
||||||
// Determine if it is a single file or a directory (multi-file torrent)
|
|
||||||
if task.Meta.Res.Name != "" {
|
|
||||||
targetPath = task.Meta.FolderPath()
|
|
||||||
} else {
|
|
||||||
targetPath = task.Meta.SingleFilepath()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if path is empty
|
|
||||||
if targetPath == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if file/folder exists
|
|
||||||
if _, err := os.Stat(targetPath); os.IsNotExist(err) {
|
|
||||||
d.Logger.Info().Msgf("Auto-cleanup: task %s file not found at %s, removing from list", task.ID, targetPath)
|
|
||||||
tasksToDelete = append(tasksToDelete, task.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(tasksToDelete) > 0 {
|
|
||||||
d.Delete(&TaskFilter{IDs: tasksToDelete}, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Downloader) parseFm(url string) (fetcher.FetcherManager, error) {
|
func (d *Downloader) parseFm(url string) (fetcher.FetcherManager, error) {
|
||||||
for _, fm := range d.cfg.FetchManagers {
|
for _, fm := range d.cfg.FetchManagers {
|
||||||
for _, filter := range fm.Filters() {
|
for _, filter := range fm.Filters() {
|
||||||
@@ -369,7 +286,7 @@ func (d *Downloader) saveTask(task *Task) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Downloader) Resolve(req *base.Request, opts *base.Options) (rr *ResolveResult, err error) {
|
func (d *Downloader) Resolve(req *base.Request) (rr *ResolveResult, err error) {
|
||||||
rrId, err := gonanoid.New()
|
rrId, err := gonanoid.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -390,11 +307,7 @@ func (d *Downloader) Resolve(req *base.Request, opts *base.Options) (rr *Resolve
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
initOpt, err := d.initOptions(opts)
|
err = fetcher.Resolve(req)
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = fetcher.Resolve(req, initOpt)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -442,21 +355,13 @@ func (d *Downloader) CreateDirect(req *base.Request, opts *base.Options) (taskId
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
fetcher.Meta().Req = req
|
fetcher.Meta().Req = req
|
||||||
initOpt, err := d.initOptions(opts)
|
return d.doCreate(fetcher, opts)
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return d.doCreate(fetcher, initOpt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Downloader) CreateDirectBatch(req *base.CreateTaskBatch) (taskId []string, err error) {
|
func (d *Downloader) CreateDirectBatch(reqs []*base.Request, opts *base.Options) (taskId []string, err error) {
|
||||||
taskIds := make([]string, 0)
|
taskIds := make([]string, 0)
|
||||||
for _, ir := range req.Reqs {
|
for _, req := range reqs {
|
||||||
opts := ir.Opts
|
taskId, err := d.CreateDirect(req, opts.Clone())
|
||||||
if opts == nil {
|
|
||||||
opts = req.Opts
|
|
||||||
}
|
|
||||||
taskId, err := d.CreateDirect(ir.Req, opts.Clone())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -465,7 +370,7 @@ func (d *Downloader) CreateDirectBatch(req *base.CreateTaskBatch) (taskId []stri
|
|||||||
return taskIds, nil
|
return taskIds, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Downloader) Create(rrId string) (taskId string, err error) {
|
func (d *Downloader) Create(rrId string, opts *base.Options) (taskId string, err error) {
|
||||||
d.fetcherMapLock.RLock()
|
d.fetcherMapLock.RLock()
|
||||||
fetcher, ok := d.fetcherCache[rrId]
|
fetcher, ok := d.fetcherCache[rrId]
|
||||||
d.fetcherMapLock.RUnlock()
|
d.fetcherMapLock.RUnlock()
|
||||||
@@ -477,48 +382,7 @@ func (d *Downloader) Create(rrId string) (taskId string, err error) {
|
|||||||
delete(d.fetcherCache, rrId)
|
delete(d.fetcherCache, rrId)
|
||||||
d.fetcherMapLock.Unlock()
|
d.fetcherMapLock.Unlock()
|
||||||
}()
|
}()
|
||||||
return d.doCreate(fetcher, nil)
|
return d.doCreate(fetcher, opts)
|
||||||
}
|
|
||||||
|
|
||||||
// Patch modifies task-specific data based on the protocol.
|
|
||||||
// For HTTP protocol, it can modify Request info.
|
|
||||||
// For BT protocol, it can modify SelectFiles.
|
|
||||||
func (d *Downloader) Patch(id string, req *base.Request, opts *base.Options) error {
|
|
||||||
task := d.GetTask(id)
|
|
||||||
if task == nil {
|
|
||||||
return ErrTaskNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore fetcher if not loaded
|
|
||||||
if task.fetcher == nil {
|
|
||||||
err := func() error {
|
|
||||||
task.statusLock.Lock()
|
|
||||||
defer task.statusLock.Unlock()
|
|
||||||
|
|
||||||
return d.restoreFetcher(task)
|
|
||||||
}()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call the fetcher's Patch method
|
|
||||||
if err := task.fetcher.Patch(req, opts); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update task meta from fetcher
|
|
||||||
task.Meta = task.fetcher.Meta()
|
|
||||||
|
|
||||||
// Save task to storage
|
|
||||||
if err := d.saveTask(task); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit progress event to notify listeners
|
|
||||||
d.emit(EventKeyProgress, task)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Downloader) Pause(filter *TaskFilter) (err error) {
|
func (d *Downloader) Pause(filter *TaskFilter) (err error) {
|
||||||
@@ -663,7 +527,7 @@ func (d *Downloader) ContinueBatch(filter *TaskFilter) (err error) {
|
|||||||
|
|
||||||
func (d *Downloader) Delete(filter *TaskFilter, force bool) (err error) {
|
func (d *Downloader) Delete(filter *TaskFilter, force bool) (err error) {
|
||||||
if filter == nil || filter.IsEmpty() {
|
if filter == nil || filter.IsEmpty() {
|
||||||
return d.deleteAll(force)
|
return d.deleteAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteTasks := d.GetTasksByFilter(filter)
|
deleteTasks := d.GetTasksByFilter(filter)
|
||||||
@@ -708,21 +572,17 @@ func (d *Downloader) Delete(filter *TaskFilter, force bool) (err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Downloader) deleteAll(force bool) (err error) {
|
func (d *Downloader) deleteAll() (err error) {
|
||||||
var deleteTasksTemp []*Task
|
|
||||||
func() {
|
func() {
|
||||||
d.lock.Lock()
|
d.lock.Lock()
|
||||||
defer d.lock.Unlock()
|
defer d.lock.Unlock()
|
||||||
|
|
||||||
for _, task := range d.tasks {
|
|
||||||
deleteTasksTemp = append(deleteTasksTemp, task)
|
|
||||||
}
|
|
||||||
d.tasks = make([]*Task, 0)
|
d.tasks = make([]*Task, 0)
|
||||||
d.waitTasks = make([]*Task, 0)
|
d.waitTasks = make([]*Task, 0)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
for _, task := range deleteTasksTemp {
|
for _, task := range d.tasks {
|
||||||
if err = d.doDelete(task, force); err != nil {
|
if err = d.doDelete(task, true); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -839,9 +699,6 @@ func (d *Downloader) emit(eventKey EventKey, task *Task, errs ...error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Downloader) GetTask(id string) *Task {
|
func (d *Downloader) GetTask(id string) *Task {
|
||||||
d.lock.Lock()
|
|
||||||
defer d.lock.Unlock()
|
|
||||||
|
|
||||||
for _, task := range d.tasks {
|
for _, task := range d.tasks {
|
||||||
if task.ID == id {
|
if task.ID == id {
|
||||||
return task
|
return task
|
||||||
@@ -851,18 +708,12 @@ func (d *Downloader) GetTask(id string) *Task {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Downloader) GetTasks() []*Task {
|
func (d *Downloader) GetTasks() []*Task {
|
||||||
d.lock.Lock()
|
|
||||||
defer d.lock.Unlock()
|
|
||||||
|
|
||||||
return d.tasks
|
return d.tasks
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTasksByFilter get tasks by filter, if filter is nil, return all tasks
|
// GetTasksByFilter get tasks by filter, if filter is nil, return all tasks
|
||||||
// return tasks and if match all tasks
|
// return tasks and if match all tasks
|
||||||
func (d *Downloader) GetTasksByFilter(filter *TaskFilter) []*Task {
|
func (d *Downloader) GetTasksByFilter(filter *TaskFilter) []*Task {
|
||||||
d.lock.Lock()
|
|
||||||
defer d.lock.Unlock()
|
|
||||||
|
|
||||||
if filter == nil || filter.IsEmpty() {
|
if filter == nil || filter.IsEmpty() {
|
||||||
return d.tasks
|
return d.tasks
|
||||||
}
|
}
|
||||||
@@ -952,6 +803,8 @@ func (d *Downloader) watch(task *Task) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
d.Logger.Warn().Err(err).Msgf("task wait upload failed, task id: %s", task.ID)
|
d.Logger.Warn().Err(err).Msgf("task wait upload failed, task id: %s", task.ID)
|
||||||
}
|
}
|
||||||
|
d.lock.Lock()
|
||||||
|
defer d.lock.Unlock()
|
||||||
|
|
||||||
// Check if the task is deleted
|
// Check if the task is deleted
|
||||||
if d.GetTask(task.ID) != nil {
|
if d.GetTask(task.ID) != nil {
|
||||||
@@ -994,31 +847,10 @@ func (d *Downloader) watch(task *Task) {
|
|||||||
d.emit(EventKeyFinally, task, err)
|
d.emit(EventKeyFinally, task, err)
|
||||||
d.notifyRunning()
|
d.notifyRunning()
|
||||||
d.triggerOnDone(task)
|
d.triggerOnDone(task)
|
||||||
d.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
|
||||||
d.triggerScripts(ScriptEventDownloadDone, task, nil)
|
|
||||||
|
|
||||||
if e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok {
|
if e, ok := task.Meta.Opts.Extra.(*http.OptsExtra); ok {
|
||||||
downloadFilePath := task.Meta.SingleFilepath()
|
downloadFilePath := task.Meta.SingleFilepath()
|
||||||
|
if e.AutoTorrent && strings.HasSuffix(downloadFilePath, ".torrent") {
|
||||||
cfg, _ := d.GetConfig()
|
|
||||||
|
|
||||||
// Determine if auto-torrent is enabled (use global config if not explicitly set)
|
|
||||||
autoTorrentEnabled := false
|
|
||||||
if e.AutoTorrent != nil {
|
|
||||||
autoTorrentEnabled = *e.AutoTorrent
|
|
||||||
} else if cfg != nil && cfg.AutoTorrent != nil {
|
|
||||||
autoTorrentEnabled = cfg.AutoTorrent.Enable
|
|
||||||
}
|
|
||||||
|
|
||||||
if autoTorrentEnabled && strings.HasSuffix(downloadFilePath, ".torrent") {
|
|
||||||
// Determine if should delete torrent file after creating BT task
|
|
||||||
shouldDelete := false
|
|
||||||
if e.DeleteTorrentAfterDownload != nil {
|
|
||||||
shouldDelete = *e.DeleteTorrentAfterDownload
|
|
||||||
} else if cfg != nil && cfg.AutoTorrent != nil {
|
|
||||||
shouldDelete = cfg.AutoTorrent.DeleteAfterDownload
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
_, err2 := d.CreateDirect(
|
_, err2 := d.CreateDirect(
|
||||||
&base.Request{
|
&base.Request{
|
||||||
@@ -1030,28 +862,10 @@ func (d *Downloader) watch(task *Task) {
|
|||||||
})
|
})
|
||||||
if err2 != nil {
|
if err2 != nil {
|
||||||
d.Logger.Error().Err(err2).Msgf("auto create torrent task failed, task id: %s", task.ID)
|
d.Logger.Error().Err(err2).Msgf("auto create torrent task failed, task id: %s", task.ID)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if shouldDelete {
|
|
||||||
d.Delete(&TaskFilter{IDs: []string{task.ID}}, true)
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if auto-extract is enabled (use global config if not explicitly set)
|
|
||||||
autoExtractEnabled := false
|
|
||||||
if e.AutoExtract != nil {
|
|
||||||
autoExtractEnabled = *e.AutoExtract
|
|
||||||
} else if cfg != nil && cfg.Archive != nil {
|
|
||||||
autoExtractEnabled = cfg.Archive.AutoExtract
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-extract archive files using the extraction queue
|
|
||||||
// This ensures only one extraction runs at a time to prevent resource exhaustion
|
|
||||||
if autoExtractEnabled && isArchiveFile(downloadFilePath) {
|
|
||||||
d.enqueueExtraction(task, downloadFilePath, e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1063,7 +877,6 @@ func (d *Downloader) doOnError(task *Task, err error) {
|
|||||||
d.emit(EventKeyError, task, err)
|
d.emit(EventKeyError, task, err)
|
||||||
d.emit(EventKeyFinally, task, err)
|
d.emit(EventKeyFinally, task, err)
|
||||||
d.notifyRunning()
|
d.notifyRunning()
|
||||||
d.triggerWebhooks(WebhookEventDownloadError, task, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1074,11 +887,23 @@ func (d *Downloader) restoreTask(task *Task) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
go d.watch(task)
|
go d.watch(task)
|
||||||
|
task.fetcher.Create(task.Meta.Opts)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Downloader) restoreFetcher(task *Task) error {
|
func (d *Downloader) restoreFetcher(task *Task) error {
|
||||||
v, f := task.fetcherManager.Restore()
|
var fm fetcher.FetcherManager
|
||||||
|
for _, f := range d.cfg.FetchManagers {
|
||||||
|
if f.Name() == task.Protocol {
|
||||||
|
fm = f
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fm == nil {
|
||||||
|
return ErrUnSupportedProtocol
|
||||||
|
}
|
||||||
|
task.fetcherManager = fm
|
||||||
|
v, f := fm.Restore()
|
||||||
if v != nil {
|
if v != nil {
|
||||||
err := d.storage.Pop(bucketSave, task.ID, v)
|
err := d.storage.Pop(bucketSave, task.ID, v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1096,15 +921,39 @@ func (d *Downloader) restoreFetcher(task *Task) error {
|
|||||||
if task.fetcher.Meta().Res == nil {
|
if task.fetcher.Meta().Res == nil {
|
||||||
task.fetcher.Meta().Res = task.Meta.Res
|
task.fetcher.Meta().Res = task.Meta.Res
|
||||||
}
|
}
|
||||||
if task.fetcher.Meta().Opts == nil {
|
|
||||||
task.fetcher.Meta().Opts = task.Meta.Opts
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId string, err error) {
|
func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId string, err error) {
|
||||||
if f.Meta().Opts == nil {
|
if opts == nil {
|
||||||
f.Meta().Opts = opts
|
opts = &base.Options{}
|
||||||
|
}
|
||||||
|
if opts.SelectFiles == nil {
|
||||||
|
opts.SelectFiles = make([]int, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := f.Meta()
|
||||||
|
meta.Opts = opts
|
||||||
|
if opts.Path == "" {
|
||||||
|
storeConfig, err := d.GetConfig()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
opts.Path = storeConfig.DownloadDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// if enable white download directory, check if the download directory is in the white list
|
||||||
|
if len(d.cfg.DownloadDirWhiteList) > 0 {
|
||||||
|
inWhiteList := false
|
||||||
|
for _, dir := range d.cfg.DownloadDirWhiteList {
|
||||||
|
if match, err := filepath.Match(dir, opts.Path); match && err == nil {
|
||||||
|
inWhiteList = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !inWhiteList {
|
||||||
|
return "", errors.New("download directory is not in white list")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fm, err := d.parseFm(f.Meta().Req.URL)
|
fm, err := d.parseFm(f.Meta().Req.URL)
|
||||||
@@ -1120,6 +969,9 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
|
|||||||
task.Progress = &Progress{}
|
task.Progress = &Progress{}
|
||||||
_, task.Uploading = f.(fetcher.Uploader)
|
_, task.Uploading = f.(fetcher.Uploader)
|
||||||
initTask(task)
|
initTask(task)
|
||||||
|
if err = f.Create(opts); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
if err = d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {
|
if err = d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1145,39 +997,6 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Downloader) initOptions(opts *base.Options) (*base.Options, error) {
|
|
||||||
if opts == nil {
|
|
||||||
opts = &base.Options{}
|
|
||||||
}
|
|
||||||
if opts.SelectFiles == nil {
|
|
||||||
opts.SelectFiles = make([]int, 0)
|
|
||||||
}
|
|
||||||
if opts.Path == "" {
|
|
||||||
storeConfig, err := d.GetConfig()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
opts.Path = storeConfig.DownloadDir
|
|
||||||
}
|
|
||||||
// Replace placeholders in download path (e.g., %year%, %month%, %day%, %date%)
|
|
||||||
opts.Path = util.ReplacePathPlaceholders(opts.Path)
|
|
||||||
|
|
||||||
// if enable white download directory, check if the download directory is in the white list
|
|
||||||
if len(d.cfg.WhiteDownloadDirs) > 0 {
|
|
||||||
inWhiteList := false
|
|
||||||
for _, dir := range d.cfg.WhiteDownloadDirs {
|
|
||||||
if match, err := filepath.Match(dir, opts.Path); match && err == nil {
|
|
||||||
inWhiteList = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !inWhiteList {
|
|
||||||
return nil, errors.New("download directory is not in white list")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return opts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Downloader) statusMut(task *Task, fn func() (bool, error)) (bool, error) {
|
func (d *Downloader) statusMut(task *Task, fn func() (bool, error)) (bool, error) {
|
||||||
task.statusLock.Lock()
|
task.statusLock.Lock()
|
||||||
defer task.statusLock.Unlock()
|
defer task.statusLock.Unlock()
|
||||||
@@ -1217,7 +1036,7 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
|||||||
|
|
||||||
d.triggerOnStart(task)
|
d.triggerOnStart(task)
|
||||||
if task.Meta.Res == nil {
|
if task.Meta.Res == nil {
|
||||||
err := task.fetcher.Resolve(task.Meta.Req, task.Meta.Opts)
|
err := task.fetcher.Resolve(task.Meta.Req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1228,10 +1047,10 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
|||||||
if task.fetcherManager.AutoRename() {
|
if task.fetcherManager.AutoRename() {
|
||||||
d.checkDuplicateLock.Lock()
|
d.checkDuplicateLock.Lock()
|
||||||
defer d.checkDuplicateLock.Unlock()
|
defer d.checkDuplicateLock.Unlock()
|
||||||
task.Meta.Opts.Name = util.SafeFilename(task.Meta.Opts.Name)
|
task.Meta.Opts.Name = util.ReplaceInvalidFilename(task.Meta.Opts.Name)
|
||||||
// check if the download file is duplicated and rename it automatically.
|
// check if the download file is duplicated and rename it automatically.
|
||||||
if task.Meta.Res.Name != "" {
|
if task.Meta.Res.Name != "" {
|
||||||
task.Meta.Res.Name = util.SafeFilename(task.Meta.Res.Name)
|
task.Meta.Res.Name = util.ReplaceInvalidFilename(task.Meta.Res.Name)
|
||||||
fullDirPath := task.Meta.FolderPath()
|
fullDirPath := task.Meta.FolderPath()
|
||||||
newName, err := util.CheckDuplicateAndRename(fullDirPath)
|
newName, err := util.CheckDuplicateAndRename(fullDirPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1239,7 +1058,7 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
|||||||
}
|
}
|
||||||
task.Meta.Opts.Name = newName
|
task.Meta.Opts.Name = newName
|
||||||
} else {
|
} else {
|
||||||
task.Meta.Res.Files[0].Name = util.SafeFilename(task.Meta.Res.Files[0].Name)
|
task.Meta.Res.Files[0].Name = util.ReplaceInvalidFilename(task.Meta.Res.Files[0].Name)
|
||||||
fullFilePath := task.Meta.SingleFilepath()
|
fullFilePath := task.Meta.SingleFilepath()
|
||||||
newName, err := util.CheckDuplicateAndRename(fullFilePath)
|
newName, err := util.CheckDuplicateAndRename(fullFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1326,15 +1145,6 @@ func logPanic(logDir string) {
|
|||||||
debug.SetCrashOutput(f, debug.CrashOptions{})
|
debug.SetCrashOutput(f, debug.CrashOptions{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Downloader) assignFetcherManager(task *Task) error {
|
|
||||||
fm, err := d.parseFm(task.Meta.Req.URL)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
task.fetcherManager = fm
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Downloader) buildFetcher(url string) (fetcher.Fetcher, error) {
|
func (d *Downloader) buildFetcher(url string) (fetcher.Fetcher, error) {
|
||||||
fm, err := d.parseFm(url)
|
fm, err := d.parseFm(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1345,384 +1155,6 @@ func (d *Downloader) buildFetcher(url string) (fetcher.Fetcher, error) {
|
|||||||
return fetcher, nil
|
return fetcher, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// enqueueExtraction adds an extraction job to the global extraction queue
|
|
||||||
// This ensures only one extraction (or one multi-part archive extraction) runs at a time
|
|
||||||
// to prevent resource exhaustion
|
|
||||||
func (d *Downloader) enqueueExtraction(task *Task, downloadFilePath string, opts *http.OptsExtra) {
|
|
||||||
partInfo := getArchivePartInfo(downloadFilePath)
|
|
||||||
|
|
||||||
if partInfo.IsMultiPart {
|
|
||||||
// For multi-part archives, handle specially
|
|
||||||
d.enqueueMultiPartExtraction(task, downloadFilePath, partInfo, opts)
|
|
||||||
} else {
|
|
||||||
// For single archives, queue immediately
|
|
||||||
d.enqueueSingleExtraction(task, downloadFilePath, opts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// enqueueSingleExtraction queues extraction for a single (non-multi-part) archive
|
|
||||||
func (d *Downloader) enqueueSingleExtraction(task *Task, downloadFilePath string, opts *http.OptsExtra) {
|
|
||||||
jobID := "single:" + task.ID
|
|
||||||
|
|
||||||
// Set extraction status to queued
|
|
||||||
task.Progress.ExtractStatus = ExtractStatusQueued
|
|
||||||
d.emit(EventKeyProgress, task)
|
|
||||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
|
||||||
d.Logger.Info().Msgf("extraction queued, task id: %s, job id: %s", task.ID, jobID)
|
|
||||||
|
|
||||||
// Create and enqueue the extraction job
|
|
||||||
job := NewExtractionJob(jobID, func() {
|
|
||||||
d.performExtraction(task, downloadFilePath, task.Meta.Opts.Path, opts)
|
|
||||||
})
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
GetExtractionQueue().Enqueue(job)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// enqueueMultiPartExtraction handles queueing for multi-part archives
|
|
||||||
// It ensures only ONE extraction job is queued when ALL parts are ready
|
|
||||||
func (d *Downloader) enqueueMultiPartExtraction(task *Task, downloadFilePath string, partInfo ArchivePartInfo, opts *http.OptsExtra) {
|
|
||||||
// Set multi-part info on the task
|
|
||||||
task.Progress.MultiPartBaseName = partInfo.BaseName
|
|
||||||
task.Progress.MultiPartNumber = partInfo.PartNumber
|
|
||||||
task.Progress.MultiPartIsFirst = isFirstPart(downloadFilePath)
|
|
||||||
|
|
||||||
// Check if all parts are downloaded
|
|
||||||
destDir := task.Meta.Opts.Path
|
|
||||||
allPartsReady, missingParts := d.checkMultiPartArchiveReady(downloadFilePath, destDir, partInfo)
|
|
||||||
|
|
||||||
if !allPartsReady {
|
|
||||||
// Not all parts are ready yet - just set status to waiting, don't queue anything
|
|
||||||
task.Progress.ExtractStatus = ExtractStatusWaitingParts
|
|
||||||
d.emit(EventKeyProgress, task)
|
|
||||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
|
||||||
d.Logger.Info().Msgf("multi-part archive waiting for other parts, task id: %s, missing: %v", task.ID, missingParts)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// All parts are ready! Atomically check if extraction has already been started/queued
|
|
||||||
// and if not, mark this task as the one that will handle it
|
|
||||||
// Use GetMultiPartArchiveBaseName to get the full path for comparison
|
|
||||||
fullBaseName := GetMultiPartArchiveBaseName(downloadFilePath)
|
|
||||||
shouldQueue := d.tryClaimMultiPartExtraction(task, fullBaseName)
|
|
||||||
|
|
||||||
if !shouldQueue {
|
|
||||||
// Another part already started/queued extraction, mark this task as done
|
|
||||||
task.Progress.ExtractStatus = ExtractStatusDone
|
|
||||||
task.Progress.ExtractProgress = 100
|
|
||||||
d.emit(EventKeyProgress, task)
|
|
||||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
|
||||||
d.Logger.Info().Msgf("multi-part archive extraction already handled by another part, task id: %s", task.ID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// This task claimed the extraction - status already set to queued in tryClaimMultiPartExtraction
|
|
||||||
d.emit(EventKeyProgress, task)
|
|
||||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
|
||||||
|
|
||||||
jobID := "multipart:" + fullBaseName
|
|
||||||
d.Logger.Info().Msgf("multi-part extraction queued, task id: %s, job id: %s", task.ID, jobID)
|
|
||||||
|
|
||||||
// Create and enqueue the extraction job
|
|
||||||
job := NewExtractionJob(jobID, func() {
|
|
||||||
d.performMultiPartExtraction(task, partInfo.FirstPartPath, destDir, opts)
|
|
||||||
})
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
GetExtractionQueue().Enqueue(job)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkMultiPartArchiveReady checks if all parts of a multi-part archive are downloaded
|
|
||||||
// by examining task status rather than file existence
|
|
||||||
func (d *Downloader) checkMultiPartArchiveReady(filePath string, destDir string, partInfo ArchivePartInfo) (bool, []string) {
|
|
||||||
// Use task-based checking - find all tasks with the same MultiPartBaseName
|
|
||||||
// and verify they are all in Done status
|
|
||||||
baseName := GetMultiPartArchiveBaseName(filePath)
|
|
||||||
if baseName == "" {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return d.checkAllMultiPartTasksDone(baseName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkAllMultiPartTasksDone checks if all tasks belonging to a multi-part archive are done
|
|
||||||
func (d *Downloader) checkAllMultiPartTasksDone(baseName string) (bool, []string) {
|
|
||||||
var notDoneParts []string
|
|
||||||
|
|
||||||
d.lock.Lock()
|
|
||||||
defer d.lock.Unlock()
|
|
||||||
|
|
||||||
// Find all tasks that belong to this multi-part archive
|
|
||||||
var relatedTasks []*Task
|
|
||||||
for _, task := range d.tasks {
|
|
||||||
taskBaseName := ""
|
|
||||||
if task.Meta != nil && task.Meta.Res != nil && len(task.Meta.Res.Files) > 0 {
|
|
||||||
taskBaseName = GetMultiPartArchiveBaseName(task.Meta.SingleFilepath())
|
|
||||||
}
|
|
||||||
if taskBaseName == baseName {
|
|
||||||
relatedTasks = append(relatedTasks, task)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we found no related tasks, we can't determine readiness
|
|
||||||
if len(relatedTasks) == 0 {
|
|
||||||
return false, []string{"no related tasks found for " + baseName}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if all related tasks are done
|
|
||||||
for _, task := range relatedTasks {
|
|
||||||
if task.Status != base.DownloadStatusDone {
|
|
||||||
notDoneParts = append(notDoneParts, task.Meta.SingleFilepath())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return len(notDoneParts) == 0, notDoneParts
|
|
||||||
}
|
|
||||||
|
|
||||||
// tryClaimMultiPartExtraction atomically checks if extraction can be claimed for a multi-part archive
|
|
||||||
// and if so, marks the task as queued. Returns true if this task should proceed with queueing.
|
|
||||||
// This uses sync.Map.LoadOrStore for atomic claim to prevent race conditions.
|
|
||||||
func (d *Downloader) tryClaimMultiPartExtraction(task *Task, baseName string) bool {
|
|
||||||
// Use LoadOrStore for atomic claim - if another goroutine already stored a value, we get that value back
|
|
||||||
_, alreadyClaimed := d.claimedExtractions.LoadOrStore(baseName, task.ID)
|
|
||||||
if alreadyClaimed {
|
|
||||||
return false // Another task already claimed it
|
|
||||||
}
|
|
||||||
|
|
||||||
// This task successfully claimed it
|
|
||||||
task.Progress.ExtractStatus = ExtractStatusQueued
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// releaseMultiPartExtractionClaim releases the extraction claim for a multi-part archive
|
|
||||||
// This is primarily used for testing purposes
|
|
||||||
func (d *Downloader) releaseMultiPartExtractionClaim(baseName string) {
|
|
||||||
d.claimedExtractions.Delete(baseName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// performExtraction performs extraction for a regular (non-multi-part) archive
|
|
||||||
func (d *Downloader) performExtraction(task *Task, archivePath string, destDir string, opts *http.OptsExtra) {
|
|
||||||
// Set extraction status to extracting
|
|
||||||
task.Progress.ExtractStatus = ExtractStatusExtracting
|
|
||||||
task.Progress.ExtractProgress = 0
|
|
||||||
d.emit(EventKeyProgress, task)
|
|
||||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
|
||||||
|
|
||||||
// Extract the archive
|
|
||||||
extractErr := extractArchive(archivePath, destDir, opts.ArchivePassword, func(extractedFiles int, totalFiles int, progress int) {
|
|
||||||
task.Progress.ExtractProgress = progress
|
|
||||||
d.emit(EventKeyProgress, task)
|
|
||||||
})
|
|
||||||
|
|
||||||
d.handleExtractionResult(task, extractErr, []string{archivePath}, opts.DeleteAfterExtract)
|
|
||||||
}
|
|
||||||
|
|
||||||
// performMultiPartExtraction performs extraction for a multi-part archive
|
|
||||||
func (d *Downloader) performMultiPartExtraction(task *Task, firstPartPath string, destDir string, opts *http.OptsExtra) {
|
|
||||||
// Get the baseName for releasing the claim later
|
|
||||||
fullBaseName := GetMultiPartArchiveBaseName(firstPartPath)
|
|
||||||
|
|
||||||
// Set extraction status to extracting
|
|
||||||
task.Progress.ExtractStatus = ExtractStatusExtracting
|
|
||||||
task.Progress.ExtractProgress = 0
|
|
||||||
d.emit(EventKeyProgress, task)
|
|
||||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
|
||||||
|
|
||||||
d.Logger.Info().Msgf("starting multi-part archive extraction, first part: %s, task id: %s", firstPartPath, task.ID)
|
|
||||||
|
|
||||||
// Extract the multi-part archive
|
|
||||||
extractErr := extractMultiPartArchive(firstPartPath, destDir, opts.ArchivePassword, func(extractedFiles int, totalFiles int, progress int) {
|
|
||||||
task.Progress.ExtractProgress = progress
|
|
||||||
d.emit(EventKeyProgress, task)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Collect all part files for potential deletion
|
|
||||||
partFiles := d.collectMultiPartFiles(firstPartPath)
|
|
||||||
|
|
||||||
d.handleExtractionResult(task, extractErr, partFiles, opts.DeleteAfterExtract)
|
|
||||||
|
|
||||||
// Update status for all related multi-part tasks
|
|
||||||
d.updateMultiPartTasksStatus(task, extractErr)
|
|
||||||
|
|
||||||
// Release the claim so future downloads of the same archive can be extracted
|
|
||||||
d.releaseMultiPartExtractionClaim(fullBaseName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectMultiPartFiles collects all files belonging to a multi-part archive
|
|
||||||
func (d *Downloader) collectMultiPartFiles(firstPartPath string) []string {
|
|
||||||
var files []string
|
|
||||||
partInfo := getArchivePartInfo(firstPartPath)
|
|
||||||
dir := filepath.Dir(firstPartPath)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case strings.Contains(partInfo.Pattern, ".7z)"):
|
|
||||||
// 7z: .7z.001, .7z.002, etc.
|
|
||||||
files = d.collectSequentialFiles(dir, partInfo.BaseName, ".%03d")
|
|
||||||
case strings.Contains(partInfo.Pattern, ".part"):
|
|
||||||
// RAR new style
|
|
||||||
files = d.collectRarNewStyleFiles(dir, partInfo.BaseName)
|
|
||||||
case partInfo.Pattern == "rar-old-style" || strings.Contains(partInfo.Pattern, ".r("):
|
|
||||||
// RAR old style
|
|
||||||
files = d.collectRarOldStyleFiles(dir, partInfo.BaseName)
|
|
||||||
case strings.Contains(partInfo.Pattern, ".zip)"):
|
|
||||||
// ZIP multi-part
|
|
||||||
files = d.collectSequentialFiles(dir, partInfo.BaseName, ".%03d")
|
|
||||||
case strings.Contains(partInfo.Pattern, ".z("):
|
|
||||||
// ZIP split
|
|
||||||
files = d.collectZipSplitFiles(dir, partInfo.BaseName)
|
|
||||||
}
|
|
||||||
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectSequentialFiles collects sequential numbered files
|
|
||||||
func (d *Downloader) collectSequentialFiles(dir, baseName, format string) []string {
|
|
||||||
var files []string
|
|
||||||
suffix := filepath.Ext(baseName)
|
|
||||||
nameWithoutExt := strings.TrimSuffix(baseName, suffix)
|
|
||||||
partNum := 1
|
|
||||||
|
|
||||||
for {
|
|
||||||
partPath := filepath.Join(dir, nameWithoutExt+suffix+fmt.Sprintf(format, partNum))
|
|
||||||
if _, err := os.Stat(partPath); os.IsNotExist(err) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
files = append(files, partPath)
|
|
||||||
partNum++
|
|
||||||
}
|
|
||||||
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectRarNewStyleFiles collects RAR new style part files
|
|
||||||
func (d *Downloader) collectRarNewStyleFiles(dir, baseName string) []string {
|
|
||||||
var files []string
|
|
||||||
partNum := 1
|
|
||||||
|
|
||||||
for {
|
|
||||||
singleDigitPath := filepath.Join(dir, baseName+fmt.Sprintf(".part%d.rar", partNum))
|
|
||||||
doubleDigitPath := filepath.Join(dir, baseName+fmt.Sprintf(".part%02d.rar", partNum))
|
|
||||||
|
|
||||||
if _, err := os.Stat(singleDigitPath); err == nil {
|
|
||||||
files = append(files, singleDigitPath)
|
|
||||||
} else if _, err := os.Stat(doubleDigitPath); err == nil {
|
|
||||||
files = append(files, doubleDigitPath)
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
partNum++
|
|
||||||
}
|
|
||||||
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectRarOldStyleFiles collects RAR old style part files
|
|
||||||
func (d *Downloader) collectRarOldStyleFiles(dir, baseName string) []string {
|
|
||||||
var files []string
|
|
||||||
|
|
||||||
// .rar file
|
|
||||||
rarPath := filepath.Join(dir, baseName+".rar")
|
|
||||||
if _, err := os.Stat(rarPath); err == nil {
|
|
||||||
files = append(files, rarPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// .r00, .r01, etc.
|
|
||||||
partNum := 0
|
|
||||||
for {
|
|
||||||
partPath := filepath.Join(dir, baseName+fmt.Sprintf(".r%02d", partNum))
|
|
||||||
if _, err := os.Stat(partPath); os.IsNotExist(err) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
files = append(files, partPath)
|
|
||||||
partNum++
|
|
||||||
}
|
|
||||||
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectZipSplitFiles collects ZIP split files
|
|
||||||
func (d *Downloader) collectZipSplitFiles(dir, baseName string) []string {
|
|
||||||
var files []string
|
|
||||||
|
|
||||||
// .z01, .z02, etc.
|
|
||||||
partNum := 1
|
|
||||||
for {
|
|
||||||
partPath := filepath.Join(dir, baseName+fmt.Sprintf(".z%02d", partNum))
|
|
||||||
if _, err := os.Stat(partPath); os.IsNotExist(err) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
files = append(files, partPath)
|
|
||||||
partNum++
|
|
||||||
}
|
|
||||||
|
|
||||||
// .zip file
|
|
||||||
zipPath := filepath.Join(dir, baseName+".zip")
|
|
||||||
if _, err := os.Stat(zipPath); err == nil {
|
|
||||||
files = append(files, zipPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleExtractionResult handles the result of an extraction operation
|
|
||||||
func (d *Downloader) handleExtractionResult(task *Task, extractErr error, archiveFiles []string, deleteAfterExtract bool) {
|
|
||||||
if extractErr != nil {
|
|
||||||
d.Logger.Error().Err(extractErr).Msgf("auto extract archive failed, task id: %s", task.ID)
|
|
||||||
task.Progress.ExtractStatus = ExtractStatusError
|
|
||||||
d.emit(EventKeyProgress, task)
|
|
||||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
|
||||||
} else {
|
|
||||||
d.Logger.Info().Msgf("auto extract archive completed, task id: %s", task.ID)
|
|
||||||
task.Progress.ExtractStatus = ExtractStatusDone
|
|
||||||
task.Progress.ExtractProgress = 100
|
|
||||||
d.emit(EventKeyProgress, task)
|
|
||||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
|
||||||
|
|
||||||
// Delete archive files after successful extraction if enabled
|
|
||||||
if deleteAfterExtract {
|
|
||||||
for _, archiveFile := range archiveFiles {
|
|
||||||
deleteErr := os.Remove(archiveFile)
|
|
||||||
if deleteErr != nil {
|
|
||||||
d.Logger.Error().Err(deleteErr).Msgf("delete archive after extraction failed: %s", archiveFile)
|
|
||||||
} else {
|
|
||||||
d.Logger.Info().Msgf("archive deleted after extraction: %s", archiveFile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateMultiPartTasksStatus updates the extraction status for all tasks that belong to the same multi-part archive
|
|
||||||
func (d *Downloader) updateMultiPartTasksStatus(sourceTask *Task, extractErr error) {
|
|
||||||
if sourceTask.Progress.MultiPartBaseName == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
status := ExtractStatusDone
|
|
||||||
progress := 100
|
|
||||||
if extractErr != nil {
|
|
||||||
status = ExtractStatusError
|
|
||||||
progress = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
d.lock.Lock()
|
|
||||||
defer d.lock.Unlock()
|
|
||||||
|
|
||||||
for _, task := range d.tasks {
|
|
||||||
if task.ID == sourceTask.ID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if task.Progress.MultiPartBaseName == sourceTask.Progress.MultiPartBaseName {
|
|
||||||
task.Progress.ExtractStatus = status
|
|
||||||
task.Progress.ExtractProgress = progress
|
|
||||||
d.emit(EventKeyProgress, task)
|
|
||||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func initTask(task *Task) {
|
func initTask(task *Task) {
|
||||||
task.timer = util.NewTimer(task.Progress.Used)
|
task.timer = util.NewTimer(task.Progress.Used)
|
||||||
|
|
||||||
@@ -1750,6 +1182,13 @@ func (b *boot) Extra(extra interface{}) *boot {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *boot) Resolve() (*ResolveResult, error) {
|
||||||
|
return defaultDownloader.Resolve(&base.Request{
|
||||||
|
URL: b.url,
|
||||||
|
Extra: b.extra,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (b *boot) Listener(listener Listener) *boot {
|
func (b *boot) Listener(listener Listener) *boot {
|
||||||
b.listener = listener
|
b.listener = listener
|
||||||
return b
|
return b
|
||||||
|
|||||||
+66
-2165
File diff suppressed because it is too large.
Load diff
@@ -205,31 +205,6 @@ function testTimeout(){
|
|||||||
xhr.send();
|
xhr.send();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testFingerprint(fingerprint,ua){
|
|
||||||
__gopeed_setFingerprint(fingerprint);
|
|
||||||
const resp = await fetch(host+'/ua');
|
|
||||||
const data = await resp.json();
|
|
||||||
if(!data.user_agent.includes(ua)){
|
|
||||||
throw new Error('fingerprint test failed, user agent: ' + data.user_agent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testFingerprintDefault(){
|
|
||||||
await testFingerprint('none', 'Go')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testFingerprintChrome(){
|
|
||||||
await testFingerprint('chrome', 'Chrome')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testFingerprintFirefox(){
|
|
||||||
await testFingerprint('firefox', 'Firefox')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testFingerprintSafari(){
|
|
||||||
await testFingerprint('safari', 'Safari')
|
|
||||||
}
|
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -313,19 +288,6 @@ async function testFingerprintSafari(){
|
|||||||
if err == nil || err.Error() != "timeout" {
|
if err == nil || err.Error() != "timeout" {
|
||||||
t.Fatalf("timeout test failed, want %s, got %s", "timeout", err)
|
t.Fatalf("timeout test failed, want %s, got %s", "timeout", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = callTestFun(engine, "testFingerprintChrome")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal("testFingerprintChrome test failed", err)
|
|
||||||
}
|
|
||||||
_, err = callTestFun(engine, "testFingerprintFirefox")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal("testFingerprintFirefox test failed", err)
|
|
||||||
}
|
|
||||||
_, err = callTestFun(engine, "testFingerprintSafari")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal("testFingerprintSafari test failed", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFetchWithProxy(t *testing.T) {
|
func TestFetchWithProxy(t *testing.T) {
|
||||||
@@ -508,15 +470,6 @@ func startServer() net.Listener {
|
|||||||
w.Write([]byte("ok"))
|
w.Write([]byte("ok"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/ua", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
data := map[string]any{
|
|
||||||
"user_agent": r.UserAgent(),
|
|
||||||
}
|
|
||||||
buf, _ := json.Marshal(data)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write(buf)
|
|
||||||
})
|
|
||||||
server.Handler = mux
|
server.Handler = mux
|
||||||
go server.Serve(listener)
|
go server.Serve(listener)
|
||||||
return listener
|
return listener
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
|
"github.com/GopeedLab/gopeed/pkg/download/engine/inject/formdata"
|
||||||
"github.com/GopeedLab/gopeed/pkg/download/engine/util"
|
"github.com/GopeedLab/gopeed/pkg/download/engine/util"
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
"github.com/imroc/req/v3"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -130,8 +129,7 @@ type XMLHttpRequest struct {
|
|||||||
requestHeaders http.Header
|
requestHeaders http.Header
|
||||||
responseHeaders http.Header
|
responseHeaders http.Header
|
||||||
aborted bool
|
aborted bool
|
||||||
client *req.Client
|
proxyHandler func(r *http.Request) (*url.URL, error)
|
||||||
fingerprint string
|
|
||||||
|
|
||||||
WithCredentials bool `json:"withCredentials"`
|
WithCredentials bool `json:"withCredentials"`
|
||||||
Upload *XMLHttpRequestUpload `json:"upload"`
|
Upload *XMLHttpRequestUpload `json:"upload"`
|
||||||
@@ -165,39 +163,25 @@ func (xhr *XMLHttpRequest) SetRequestHeader(key, value string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (xhr *XMLHttpRequest) Send(data goja.Value) {
|
func (xhr *XMLHttpRequest) Send(data goja.Value) {
|
||||||
setFingerprint(xhr.client, xhr.fingerprint)
|
var req *http.Request
|
||||||
|
var err error
|
||||||
d := xhr.parseData(data)
|
d := xhr.parseData(data)
|
||||||
var (
|
var (
|
||||||
contentType string
|
contentType string
|
||||||
contentLength int64
|
contentLength int64
|
||||||
isStringBody bool
|
isStringBody bool
|
||||||
)
|
)
|
||||||
|
if d == nil || xhr.method == "GET" || xhr.method == "HEAD" {
|
||||||
// Create request using req library
|
req, err = http.NewRequest(xhr.method, xhr.url, nil)
|
||||||
reqBuilder := xhr.client.R()
|
} else {
|
||||||
|
|
||||||
// Set headers first
|
|
||||||
if xhr.requestHeaders != nil {
|
|
||||||
for key, values := range xhr.requestHeaders {
|
|
||||||
if len(values) > 0 {
|
|
||||||
// Merge multiple values with comma separator (HTTP standard)
|
|
||||||
mergedValue := strings.Join(values, ", ")
|
|
||||||
reqBuilder.SetHeader(key, mergedValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle request body
|
|
||||||
if d != nil && xhr.method != "GET" && xhr.method != "HEAD" {
|
|
||||||
switch v := d.(type) {
|
switch v := d.(type) {
|
||||||
case string:
|
case string:
|
||||||
reqBuilder.SetBody(v)
|
req, err = http.NewRequest(xhr.method, xhr.url, bytes.NewBufferString(v))
|
||||||
contentType = "text/plain;charset=UTF-8"
|
contentType = "text/plain;charset=UTF-8"
|
||||||
contentLength = int64(len(v))
|
contentLength = int64(len(v))
|
||||||
isStringBody = true
|
isStringBody = true
|
||||||
case *file.File:
|
case *file.File:
|
||||||
reqBuilder.SetBody(v.Reader)
|
req, err = http.NewRequest(xhr.method, xhr.url, v.Reader)
|
||||||
contentType = "application/octet-stream"
|
contentType = "application/octet-stream"
|
||||||
contentLength = v.Size
|
contentLength = v.Size
|
||||||
case *formdata.FormData:
|
case *formdata.FormData:
|
||||||
@@ -219,38 +203,43 @@ func (xhr *XMLHttpRequest) Send(data goja.Value) {
|
|||||||
defer mw.Close()
|
defer mw.Close()
|
||||||
mw.Send()
|
mw.Send()
|
||||||
}()
|
}()
|
||||||
reqBuilder.SetBody(pr)
|
req, err = http.NewRequest(xhr.method, xhr.url, pr)
|
||||||
contentType = mw.FormDataContentType()
|
contentType = mw.FormDataContentType()
|
||||||
contentLength = mw.Size()
|
contentLength = mw.Size()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
xhr.callOnerror()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header = xhr.requestHeaders
|
||||||
// Only string body can specify Content-Type header by user
|
// Only string body can specify Content-Type header by user
|
||||||
if contentType != "" && (!isStringBody || xhr.requestHeaders.Get("Content-Type") == "") {
|
if contentType != "" && (!isStringBody || req.Header.Get("Content-Type") == "") {
|
||||||
reqBuilder.SetHeader("Content-Type", contentType)
|
req.Header.Set("Content-Type", contentType)
|
||||||
}
|
}
|
||||||
|
if contentLength > 0 {
|
||||||
// Set timeout
|
req.ContentLength = contentLength
|
||||||
if xhr.Timeout > 0 {
|
|
||||||
xhr.client.SetTimeout(time.Duration(xhr.Timeout) * time.Millisecond)
|
|
||||||
}
|
}
|
||||||
|
transport := &http.Transport{
|
||||||
// Configure redirect behavior
|
Proxy: xhr.proxyHandler,
|
||||||
xhr.client.SetRedirectPolicy(func(req *http.Request, via []*http.Request) error {
|
}
|
||||||
if xhr.Redirect == redirectManual {
|
client := &http.Client{
|
||||||
return http.ErrUseLastResponse
|
Transport: transport,
|
||||||
}
|
Timeout: time.Duration(xhr.Timeout) * time.Millisecond,
|
||||||
if xhr.Redirect == redirectError {
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
return errors.New("redirect failed")
|
if xhr.Redirect == redirectManual {
|
||||||
}
|
return http.ErrUseLastResponse
|
||||||
if len(via) > 20 {
|
}
|
||||||
return errors.New("too many redirects")
|
if xhr.Redirect == redirectError {
|
||||||
}
|
return errors.New("redirect failed")
|
||||||
return nil
|
}
|
||||||
})
|
if len(via) > 20 {
|
||||||
|
return errors.New("too many redirects")
|
||||||
// Execute request
|
}
|
||||||
resp, err := reqBuilder.Send(xhr.method, xhr.url)
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// handle timeout error
|
// handle timeout error
|
||||||
var ne net.Error
|
var ne net.Error
|
||||||
@@ -265,37 +254,35 @@ func (xhr *XMLHttpRequest) Send(data goja.Value) {
|
|||||||
xhr.callOnerror()
|
xhr.callOnerror()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
xhr.Upload.callOnprogress(contentLength, contentLength)
|
xhr.Upload.callOnprogress(contentLength, contentLength)
|
||||||
if !xhr.aborted {
|
if !xhr.aborted {
|
||||||
xhr.Upload.callOnload()
|
xhr.Upload.callOnload()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set response URL (final URL after redirects)
|
responseUrl := resp.Request.URL
|
||||||
if resp.Response != nil && resp.Response.Request != nil && resp.Response.Request.URL != nil {
|
responseUrl.Fragment = ""
|
||||||
responseUrl := resp.Response.Request.URL
|
xhr.ResponseUrl = responseUrl.String()
|
||||||
responseUrl.Fragment = ""
|
|
||||||
xhr.ResponseUrl = responseUrl.String()
|
|
||||||
} else {
|
|
||||||
xhr.ResponseUrl = xhr.url
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set response headers
|
|
||||||
xhr.responseHeaders = resp.Header
|
xhr.responseHeaders = resp.Header
|
||||||
xhr.Status = resp.StatusCode
|
xhr.Status = resp.StatusCode
|
||||||
xhr.StatusText = resp.Status
|
xhr.StatusText = resp.Status
|
||||||
xhr.doReadystatechange(2)
|
xhr.doReadystatechange(2)
|
||||||
|
buf, err := io.ReadAll(resp.Body)
|
||||||
bodyBytes := resp.Bytes()
|
if err != nil {
|
||||||
|
xhr.callOnerror()
|
||||||
|
return
|
||||||
|
}
|
||||||
xhr.doReadystatechange(3)
|
xhr.doReadystatechange(3)
|
||||||
xhr.Response = string(bodyBytes)
|
xhr.Response = string(buf)
|
||||||
xhr.ResponseText = xhr.Response
|
xhr.ResponseText = xhr.Response
|
||||||
xhr.doReadystatechange(4)
|
xhr.doReadystatechange(4)
|
||||||
respBodyLen := int64(len(bodyBytes))
|
respBodyLen := int64(len(buf))
|
||||||
xhr.callOnprogress(respBodyLen, respBodyLen)
|
xhr.callOnprogress(respBodyLen, respBodyLen)
|
||||||
if !xhr.aborted {
|
if !xhr.aborted {
|
||||||
xhr.callOnload()
|
xhr.callOnload()
|
||||||
}
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (xhr *XMLHttpRequest) Abort() {
|
func (xhr *XMLHttpRequest) Abort() {
|
||||||
@@ -306,9 +293,6 @@ func (xhr *XMLHttpRequest) Abort() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (xhr *XMLHttpRequest) GetResponseHeader(key string) string {
|
func (xhr *XMLHttpRequest) GetResponseHeader(key string) string {
|
||||||
if xhr.responseHeaders == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.Join(xhr.responseHeaders.Values(key), ", ")
|
return strings.Join(xhr.responseHeaders.Values(key), ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,15 +359,8 @@ func Enable(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL,
|
|||||||
return instanceValue
|
return instanceValue
|
||||||
})
|
})
|
||||||
xhr := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object {
|
xhr := runtime.ToValue(func(call goja.ConstructorCall) *goja.Object {
|
||||||
// Create req client with proxy support
|
|
||||||
client := req.C()
|
|
||||||
if proxyHandler != nil {
|
|
||||||
client.SetProxy(proxyHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
instance := &XMLHttpRequest{
|
instance := &XMLHttpRequest{
|
||||||
client: client,
|
proxyHandler: proxyHandler,
|
||||||
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)),
|
||||||
@@ -397,9 +374,6 @@ 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 string) {
|
|
||||||
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,24 +0,0 @@
|
|||||||
package xhr
|
|
||||||
|
|
||||||
import "github.com/imroc/req/v3"
|
|
||||||
|
|
||||||
type Fingerprint string
|
|
||||||
|
|
||||||
const (
|
|
||||||
FingerprintMagicKey = "__gopeed_xhr_fingerprint"
|
|
||||||
|
|
||||||
fingerprintChrome = "chrome"
|
|
||||||
fingerprintFirefox = "firefox"
|
|
||||||
fingerprintSafari = "safari"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setFingerprint(client *req.Client, fingerprint string) {
|
|
||||||
switch fingerprint {
|
|
||||||
case fingerprintChrome:
|
|
||||||
client.ImpersonateChrome()
|
|
||||||
case fingerprintFirefox:
|
|
||||||
client.ImpersonateFirefox()
|
|
||||||
case fingerprintSafari:
|
|
||||||
client.ImpersonateSafari()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+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-opts": "1.11.1",
|
"@webassemblyjs/wasm-opt": "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-opts": {
|
"node_modules/@webassemblyjs/wasm-opt": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-opts/-/wasm-opts-1.11.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-opt/-/wasm-opt-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-opts": "1.11.1",
|
"@webassemblyjs/wasm-opt": "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-opts": {
|
"@webassemblyjs/wasm-opt": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-opts/-/wasm-opts-1.11.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz",
|
||||||
"integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==",
|
"integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
|
|||||||
@@ -22,16 +22,3 @@ 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
|
|
||||||
}
|
|
||||||
+12
-16
@@ -3,14 +3,6 @@ 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"
|
||||||
@@ -20,6 +12,13 @@ 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 (
|
||||||
@@ -201,14 +200,10 @@ func (d *Downloader) fetchExtensionByGit(url string, handler func(tempExtPath st
|
|||||||
subPath = pathArr[1]
|
subPath = pathArr[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use unique suffix to avoid concurrent conflicts when multiple git operations
|
tempExtDir := filepath.Join(d.cfg.StorageDir, tempExtensionsDir, projectPath)
|
||||||
// target the same extension (e.g., install and update check happening simultaneously)
|
if err := util.RmAndMkDirAll(tempExtDir); err != nil {
|
||||||
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 {
|
||||||
@@ -225,6 +220,7 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -264,9 +260,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.SafeFilename(ctx.Res.Name)
|
ctx.Res.Name = util.ReplaceInvalidFilename(ctx.Res.Name)
|
||||||
for _, file := range ctx.Res.Files {
|
for _, file := range ctx.Res.Files {
|
||||||
file.Name = util.SafeFilename(file.Name)
|
file.Name = util.ReplaceInvalidFilename(file.Name)
|
||||||
}
|
}
|
||||||
ctx.Res.CalcSize(nil)
|
ctx.Res.CalcSize(nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ 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) {
|
||||||
@@ -19,7 +18,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)
|
||||||
}
|
}
|
||||||
@@ -36,7 +35,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)
|
||||||
}
|
}
|
||||||
@@ -53,7 +52,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)
|
||||||
}
|
}
|
||||||
@@ -70,7 +69,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)
|
||||||
}
|
}
|
||||||
@@ -87,7 +86,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)
|
||||||
}
|
}
|
||||||
@@ -157,7 +156,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)
|
||||||
}
|
}
|
||||||
@@ -186,7 +185,7 @@ func TestDownloader_Extension_OnStart(t *testing.T) {
|
|||||||
select {
|
select {
|
||||||
case err = <-errCh:
|
case err = <-errCh:
|
||||||
break
|
break
|
||||||
case <-time.After(time.Second * 30): // Increased timeout for real network requests
|
case <-time.After(time.Second * 10):
|
||||||
err = errors.New("timeout")
|
err = errors.New("timeout")
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -239,7 +238,7 @@ func TestDownloader_Extension_OnError(t *testing.T) {
|
|||||||
select {
|
select {
|
||||||
case err = <-errCh:
|
case err = <-errCh:
|
||||||
break
|
break
|
||||||
case <-time.After(time.Second * 30): // Increased timeout for real network requests
|
case <-time.After(time.Second * 10):
|
||||||
err = errors.New("timeout")
|
err = errors.New("timeout")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +273,7 @@ func TestDownloader_Extension_OnDone(t *testing.T) {
|
|||||||
select {
|
select {
|
||||||
case err = <-errCh:
|
case err = <-errCh:
|
||||||
break
|
break
|
||||||
case <-time.After(time.Second * 30): // Increased timeout for real network requests
|
case <-time.After(time.Second * 10):
|
||||||
err = errors.New("timeout")
|
err = errors.New("timeout")
|
||||||
}
|
}
|
||||||
// wait for script execution
|
// wait for script execution
|
||||||
@@ -301,7 +300,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)
|
||||||
}
|
}
|
||||||
@@ -316,7 +315,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)
|
||||||
}
|
}
|
||||||
@@ -331,7 +330,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")
|
||||||
}
|
}
|
||||||
@@ -353,7 +352,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)
|
||||||
}
|
}
|
||||||
@@ -374,7 +373,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)
|
||||||
}
|
}
|
||||||
@@ -391,7 +390,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,479 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,545 +0,0 @@
|
|||||||
package download
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestNewExtractionQueue(t *testing.T) {
|
|
||||||
q := NewExtractionQueue()
|
|
||||||
if q == nil {
|
|
||||||
t.Fatal("NewExtractionQueue returned nil")
|
|
||||||
}
|
|
||||||
if q.jobs == nil {
|
|
||||||
t.Fatal("jobs slice should be initialized")
|
|
||||||
}
|
|
||||||
if len(q.jobs) != 0 {
|
|
||||||
t.Fatalf("expected empty jobs slice, got %d", len(q.jobs))
|
|
||||||
}
|
|
||||||
if q.running {
|
|
||||||
t.Fatal("queue should not be running initially")
|
|
||||||
}
|
|
||||||
if q.cond == nil {
|
|
||||||
t.Fatal("condition variable should be initialized")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractionQueue_StartStop(t *testing.T) {
|
|
||||||
q := NewExtractionQueue()
|
|
||||||
|
|
||||||
// Test Start
|
|
||||||
q.Start()
|
|
||||||
if !q.IsRunning() {
|
|
||||||
t.Fatal("queue should be running after Start")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test double Start (should be idempotent)
|
|
||||||
q.Start()
|
|
||||||
if !q.IsRunning() {
|
|
||||||
t.Fatal("queue should still be running after second Start")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test Stop
|
|
||||||
q.Stop()
|
|
||||||
if q.IsRunning() {
|
|
||||||
t.Fatal("queue should not be running after Stop")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test double Stop (should be idempotent)
|
|
||||||
q.Stop()
|
|
||||||
if q.IsRunning() {
|
|
||||||
t.Fatal("queue should still not be running after second Stop")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractionQueue_EnqueueSingleJob(t *testing.T) {
|
|
||||||
q := NewExtractionQueue()
|
|
||||||
q.Start()
|
|
||||||
defer q.Stop()
|
|
||||||
|
|
||||||
executed := false
|
|
||||||
job := NewExtractionJob("test-1", func() {
|
|
||||||
executed = true
|
|
||||||
})
|
|
||||||
|
|
||||||
q.Enqueue(job)
|
|
||||||
job.Wait()
|
|
||||||
|
|
||||||
if !executed {
|
|
||||||
t.Fatal("job should have been executed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractionQueue_EnqueueAndWait(t *testing.T) {
|
|
||||||
q := NewExtractionQueue()
|
|
||||||
q.Start()
|
|
||||||
defer q.Stop()
|
|
||||||
|
|
||||||
executed := false
|
|
||||||
job := NewExtractionJob("test-1", func() {
|
|
||||||
executed = true
|
|
||||||
})
|
|
||||||
|
|
||||||
q.EnqueueAndWait(job)
|
|
||||||
|
|
||||||
if !executed {
|
|
||||||
t.Fatal("job should have been executed after EnqueueAndWait returns")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractionQueue_FIFOOrder(t *testing.T) {
|
|
||||||
q := NewExtractionQueue()
|
|
||||||
// Don't start yet - we want to queue multiple jobs first
|
|
||||||
|
|
||||||
var executionOrder []string
|
|
||||||
var mu sync.Mutex
|
|
||||||
|
|
||||||
addToOrder := func(id string) {
|
|
||||||
mu.Lock()
|
|
||||||
executionOrder = append(executionOrder, id)
|
|
||||||
mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
job1 := NewExtractionJob("job-1", func() {
|
|
||||||
addToOrder("job-1")
|
|
||||||
})
|
|
||||||
job2 := NewExtractionJob("job-2", func() {
|
|
||||||
addToOrder("job-2")
|
|
||||||
})
|
|
||||||
job3 := NewExtractionJob("job-3", func() {
|
|
||||||
addToOrder("job-3")
|
|
||||||
})
|
|
||||||
|
|
||||||
// Enqueue jobs before starting (they'll wait in queue)
|
|
||||||
q.mu.Lock()
|
|
||||||
q.jobs = append(q.jobs, job1, job2, job3)
|
|
||||||
q.mu.Unlock()
|
|
||||||
|
|
||||||
// Now start processing
|
|
||||||
q.Start()
|
|
||||||
defer q.Stop()
|
|
||||||
|
|
||||||
// Wait for all jobs
|
|
||||||
job1.Wait()
|
|
||||||
job2.Wait()
|
|
||||||
job3.Wait()
|
|
||||||
|
|
||||||
// Verify FIFO order
|
|
||||||
if len(executionOrder) != 3 {
|
|
||||||
t.Fatalf("expected 3 executions, got %d", len(executionOrder))
|
|
||||||
}
|
|
||||||
if executionOrder[0] != "job-1" || executionOrder[1] != "job-2" || executionOrder[2] != "job-3" {
|
|
||||||
t.Fatalf("expected FIFO order [job-1, job-2, job-3], got %v", executionOrder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractionQueue_SequentialExecution(t *testing.T) {
|
|
||||||
q := NewExtractionQueue()
|
|
||||||
q.Start()
|
|
||||||
defer q.Stop()
|
|
||||||
|
|
||||||
var activeJobs int32
|
|
||||||
var maxConcurrent int32
|
|
||||||
var mu sync.Mutex
|
|
||||||
|
|
||||||
// Create jobs that take some time to execute
|
|
||||||
createJob := func(id string) *ExtractionJob {
|
|
||||||
return NewExtractionJob(id, func() {
|
|
||||||
current := atomic.AddInt32(&activeJobs, 1)
|
|
||||||
mu.Lock()
|
|
||||||
if current > maxConcurrent {
|
|
||||||
maxConcurrent = current
|
|
||||||
}
|
|
||||||
mu.Unlock()
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
atomic.AddInt32(&activeJobs, -1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs := make([]*ExtractionJob, 5)
|
|
||||||
for i := range jobs {
|
|
||||||
jobs[i] = q.Enqueue(createJob(string(rune('A' + i))))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for all jobs
|
|
||||||
for _, job := range jobs {
|
|
||||||
job.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify only one job ran at a time
|
|
||||||
if maxConcurrent > 1 {
|
|
||||||
t.Fatalf("expected max 1 concurrent job, got %d", maxConcurrent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractionQueue_QueueLength(t *testing.T) {
|
|
||||||
q := NewExtractionQueue()
|
|
||||||
|
|
||||||
if q.QueueLength() != 0 {
|
|
||||||
t.Fatalf("expected queue length 0, got %d", q.QueueLength())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add jobs without starting (they'll stay in queue)
|
|
||||||
blockChan := make(chan struct{})
|
|
||||||
blockingJob := NewExtractionJob("blocking", func() {
|
|
||||||
<-blockChan // Block until signaled
|
|
||||||
})
|
|
||||||
|
|
||||||
q.Start()
|
|
||||||
defer q.Stop()
|
|
||||||
|
|
||||||
q.Enqueue(blockingJob)
|
|
||||||
|
|
||||||
// Give worker time to pick up the blocking job
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
// Queue more jobs while blocking job is running
|
|
||||||
job2 := q.Enqueue(NewExtractionJob("job-2", func() {}))
|
|
||||||
job3 := q.Enqueue(NewExtractionJob("job-3", func() {}))
|
|
||||||
|
|
||||||
// Should have 2 jobs waiting
|
|
||||||
if q.QueueLength() != 2 {
|
|
||||||
t.Fatalf("expected queue length 2, got %d", q.QueueLength())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unblock and wait
|
|
||||||
close(blockChan)
|
|
||||||
blockingJob.Wait()
|
|
||||||
job2.Wait()
|
|
||||||
job3.Wait()
|
|
||||||
|
|
||||||
if q.QueueLength() != 0 {
|
|
||||||
t.Fatalf("expected queue length 0 after completion, got %d", q.QueueLength())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractionQueue_HasPendingJob(t *testing.T) {
|
|
||||||
q := NewExtractionQueue()
|
|
||||||
|
|
||||||
blockChan := make(chan struct{})
|
|
||||||
blockingJob := NewExtractionJob("blocking", func() {
|
|
||||||
<-blockChan
|
|
||||||
})
|
|
||||||
|
|
||||||
q.Start()
|
|
||||||
defer q.Stop()
|
|
||||||
|
|
||||||
q.Enqueue(blockingJob)
|
|
||||||
time.Sleep(20 * time.Millisecond) // Let blocking job start
|
|
||||||
|
|
||||||
// Queue more jobs
|
|
||||||
job2 := q.Enqueue(NewExtractionJob("job-2", func() {}))
|
|
||||||
|
|
||||||
if !q.HasPendingJob("job-2") {
|
|
||||||
t.Fatal("expected HasPendingJob to return true for job-2")
|
|
||||||
}
|
|
||||||
|
|
||||||
if q.HasPendingJob("job-99") {
|
|
||||||
t.Fatal("expected HasPendingJob to return false for non-existent job")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unblock and wait
|
|
||||||
close(blockChan)
|
|
||||||
blockingJob.Wait()
|
|
||||||
job2.Wait()
|
|
||||||
|
|
||||||
if q.HasPendingJob("job-2") {
|
|
||||||
t.Fatal("expected HasPendingJob to return false after job completion")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractionQueue_RemovePendingJob(t *testing.T) {
|
|
||||||
q := NewExtractionQueue()
|
|
||||||
|
|
||||||
blockChan := make(chan struct{})
|
|
||||||
blockingJob := NewExtractionJob("blocking", func() {
|
|
||||||
<-blockChan
|
|
||||||
})
|
|
||||||
|
|
||||||
executed := false
|
|
||||||
job2 := NewExtractionJob("job-2", func() {
|
|
||||||
executed = true
|
|
||||||
})
|
|
||||||
|
|
||||||
q.Start()
|
|
||||||
defer q.Stop()
|
|
||||||
|
|
||||||
q.Enqueue(blockingJob)
|
|
||||||
time.Sleep(20 * time.Millisecond) // Let blocking job start
|
|
||||||
q.Enqueue(job2)
|
|
||||||
|
|
||||||
// Remove job-2 before it executes
|
|
||||||
removed := q.RemovePendingJob("job-2")
|
|
||||||
if !removed {
|
|
||||||
t.Fatal("expected RemovePendingJob to return true")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to remove again (should return false)
|
|
||||||
removed = q.RemovePendingJob("job-2")
|
|
||||||
if removed {
|
|
||||||
t.Fatal("expected second RemovePendingJob to return false")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unblock
|
|
||||||
close(blockChan)
|
|
||||||
blockingJob.Wait()
|
|
||||||
|
|
||||||
// Wait a bit for any potential execution
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
|
||||||
if executed {
|
|
||||||
t.Fatal("removed job should not have been executed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// The removed job's done channel should be closed
|
|
||||||
select {
|
|
||||||
case <-job2.done:
|
|
||||||
// Expected
|
|
||||||
default:
|
|
||||||
t.Fatal("removed job's done channel should be closed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractionQueue_StopDiscardsPendingJobs(t *testing.T) {
|
|
||||||
q := NewExtractionQueue()
|
|
||||||
|
|
||||||
blockChan := make(chan struct{})
|
|
||||||
executed1 := false
|
|
||||||
executed2 := false
|
|
||||||
|
|
||||||
blockingJob := NewExtractionJob("blocking", func() {
|
|
||||||
<-blockChan
|
|
||||||
executed1 = true
|
|
||||||
})
|
|
||||||
pendingJob := NewExtractionJob("pending", func() {
|
|
||||||
executed2 = true
|
|
||||||
})
|
|
||||||
|
|
||||||
q.Start()
|
|
||||||
|
|
||||||
q.Enqueue(blockingJob)
|
|
||||||
time.Sleep(20 * time.Millisecond) // Let blocking job start
|
|
||||||
q.Enqueue(pendingJob)
|
|
||||||
|
|
||||||
// Stop without unblocking
|
|
||||||
go func() {
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
close(blockChan)
|
|
||||||
}()
|
|
||||||
q.Stop()
|
|
||||||
|
|
||||||
// Blocking job should have completed, but pending job was discarded
|
|
||||||
if !executed1 {
|
|
||||||
t.Log("Note: blocking job may not complete if Stop() won races")
|
|
||||||
}
|
|
||||||
if executed2 {
|
|
||||||
t.Fatal("pending job should not have been executed after Stop")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pending job's done channel should be closed
|
|
||||||
select {
|
|
||||||
case <-pendingJob.done:
|
|
||||||
// Expected
|
|
||||||
default:
|
|
||||||
t.Fatal("pending job's done channel should be closed after Stop")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractionQueue_EnqueueAfterShutdown(t *testing.T) {
|
|
||||||
q := NewExtractionQueue()
|
|
||||||
q.Start()
|
|
||||||
q.Stop()
|
|
||||||
|
|
||||||
executed := false
|
|
||||||
job := NewExtractionJob("after-shutdown", func() {
|
|
||||||
executed = true
|
|
||||||
})
|
|
||||||
|
|
||||||
q.Enqueue(job)
|
|
||||||
|
|
||||||
// Job should be immediately done (without execution)
|
|
||||||
select {
|
|
||||||
case <-job.done:
|
|
||||||
// Expected
|
|
||||||
case <-time.After(100 * time.Millisecond):
|
|
||||||
t.Fatal("job should be immediately done after shutdown")
|
|
||||||
}
|
|
||||||
|
|
||||||
if executed {
|
|
||||||
t.Fatal("job should not be executed after shutdown")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewExtractionJob(t *testing.T) {
|
|
||||||
job := NewExtractionJob("test-id", func() {})
|
|
||||||
|
|
||||||
if job.ID != "test-id" {
|
|
||||||
t.Fatalf("expected ID 'test-id', got '%s'", job.ID)
|
|
||||||
}
|
|
||||||
if job.Execute == nil {
|
|
||||||
t.Fatal("Execute should not be nil")
|
|
||||||
}
|
|
||||||
if job.done == nil {
|
|
||||||
t.Fatal("done channel should be initialized")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractionJob_Wait(t *testing.T) {
|
|
||||||
job := NewExtractionJob("test", func() {})
|
|
||||||
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
job.Wait()
|
|
||||||
close(done)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Wait should block
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
t.Fatal("Wait should block until job is done")
|
|
||||||
case <-time.After(50 * time.Millisecond):
|
|
||||||
// Expected
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close done channel
|
|
||||||
close(job.done)
|
|
||||||
|
|
||||||
// Now Wait should return
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
// Expected
|
|
||||||
case <-time.After(100 * time.Millisecond):
|
|
||||||
t.Fatal("Wait should return after job is done")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractionQueue_NilExecuteFunction(t *testing.T) {
|
|
||||||
q := NewExtractionQueue()
|
|
||||||
q.Start()
|
|
||||||
defer q.Stop()
|
|
||||||
|
|
||||||
// Job with nil Execute should not panic
|
|
||||||
job := &ExtractionJob{
|
|
||||||
ID: "nil-execute",
|
|
||||||
Execute: nil,
|
|
||||||
done: make(chan struct{}),
|
|
||||||
}
|
|
||||||
|
|
||||||
q.Enqueue(job)
|
|
||||||
job.Wait()
|
|
||||||
// No panic means success
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractionQueue_ConcurrentEnqueue(t *testing.T) {
|
|
||||||
q := NewExtractionQueue()
|
|
||||||
q.Start()
|
|
||||||
defer q.Stop()
|
|
||||||
|
|
||||||
var counter int32
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
numGoroutines := 10
|
|
||||||
jobsPerGoroutine := 10
|
|
||||||
|
|
||||||
wg.Add(numGoroutines)
|
|
||||||
for i := 0; i < numGoroutines; i++ {
|
|
||||||
go func(goroutineID int) {
|
|
||||||
defer wg.Done()
|
|
||||||
for j := 0; j < jobsPerGoroutine; j++ {
|
|
||||||
job := NewExtractionJob("", func() {
|
|
||||||
atomic.AddInt32(&counter, 1)
|
|
||||||
})
|
|
||||||
q.EnqueueAndWait(job)
|
|
||||||
}
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
expected := int32(numGoroutines * jobsPerGoroutine)
|
|
||||||
if counter != expected {
|
|
||||||
t.Fatalf("expected counter %d, got %d", expected, counter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractionQueue_Restart(t *testing.T) {
|
|
||||||
q := NewExtractionQueue()
|
|
||||||
|
|
||||||
// First run
|
|
||||||
q.Start()
|
|
||||||
executed1 := false
|
|
||||||
job1 := NewExtractionJob("job-1", func() {
|
|
||||||
executed1 = true
|
|
||||||
})
|
|
||||||
q.EnqueueAndWait(job1)
|
|
||||||
q.Stop()
|
|
||||||
|
|
||||||
if !executed1 {
|
|
||||||
t.Fatal("first job should have been executed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restart
|
|
||||||
q.Start()
|
|
||||||
executed2 := false
|
|
||||||
job2 := NewExtractionJob("job-2", func() {
|
|
||||||
executed2 = true
|
|
||||||
})
|
|
||||||
q.EnqueueAndWait(job2)
|
|
||||||
q.Stop()
|
|
||||||
|
|
||||||
if !executed2 {
|
|
||||||
t.Fatal("second job should have been executed after restart")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetExtractionQueue(t *testing.T) {
|
|
||||||
// Note: This test modifies global state, so it should ideally be run in isolation
|
|
||||||
// However, the implementation ensures the queue is created only once
|
|
||||||
|
|
||||||
queue := GetExtractionQueue()
|
|
||||||
if queue == nil {
|
|
||||||
t.Fatal("GetExtractionQueue should not return nil")
|
|
||||||
}
|
|
||||||
if !queue.IsRunning() {
|
|
||||||
t.Fatal("global queue should be running")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calling again should return the same instance
|
|
||||||
queue2 := GetExtractionQueue()
|
|
||||||
if queue != queue2 {
|
|
||||||
t.Fatal("GetExtractionQueue should return the same instance")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractionQueue_LongRunningJob(t *testing.T) {
|
|
||||||
q := NewExtractionQueue()
|
|
||||||
q.Start()
|
|
||||||
defer q.Stop()
|
|
||||||
|
|
||||||
startTime := time.Now()
|
|
||||||
longJobDuration := 100 * time.Millisecond
|
|
||||||
|
|
||||||
longJob := NewExtractionJob("long-job", func() {
|
|
||||||
time.Sleep(longJobDuration)
|
|
||||||
})
|
|
||||||
|
|
||||||
shortJobExecuted := false
|
|
||||||
shortJob := NewExtractionJob("short-job", func() {
|
|
||||||
shortJobExecuted = true
|
|
||||||
})
|
|
||||||
|
|
||||||
q.Enqueue(longJob)
|
|
||||||
q.Enqueue(shortJob)
|
|
||||||
|
|
||||||
shortJob.Wait()
|
|
||||||
|
|
||||||
elapsed := time.Since(startTime)
|
|
||||||
if elapsed < longJobDuration {
|
|
||||||
t.Fatalf("short job should wait for long job, elapsed: %v", elapsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !shortJobExecuted {
|
|
||||||
t.Fatal("short job should have been executed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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
@@ -1,191 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
+12
-24
@@ -2,9 +2,6 @@ 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"
|
||||||
@@ -12,6 +9,8 @@ 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 {
|
||||||
@@ -101,27 +100,16 @@ func (t *Task) clone() *Task {
|
|||||||
return util.DeepClone(t)
|
return util.DeepClone(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Task) updateSpeed(downloaded int64, usedTime float64) int64 {
|
func (t *Task) calcSpeed(speedArr []int64, downloaded int64, usedTime float64) int64 {
|
||||||
return calcSpeed(&t.speedArr, downloaded, usedTime)
|
speedArr = append(speedArr, downloaded)
|
||||||
}
|
if len(speedArr) > 6 {
|
||||||
|
speedArr = speedArr[1:]
|
||||||
func (t *Task) updateUploadSpeed(downloaded int64, usedTime float64) int64 {
|
|
||||||
return calcSpeed(&t.uploadSpeedArr, downloaded, usedTime)
|
|
||||||
}
|
|
||||||
|
|
||||||
func calcSpeed(speedArr *[]int64, downloaded int64, usedTime float64) int64 {
|
|
||||||
*speedArr = append(*speedArr, downloaded)
|
|
||||||
// Record last 5 seconds of download speed to calculate the average speed
|
|
||||||
if len(*speedArr) > int(5.0/usedTime) {
|
|
||||||
*speedArr = (*speedArr)[1:]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
for _, v := range *speedArr {
|
for _, v := range speedArr {
|
||||||
total += v
|
total += v
|
||||||
}
|
}
|
||||||
|
return int64(float64(total) / float64(len(speedArr)) / usedTime)
|
||||||
return int64(float64(total) / float64(len(*speedArr)) / usedTime)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TaskFilter struct {
|
type TaskFilter struct {
|
||||||
@@ -138,10 +126,10 @@ type DownloaderConfig struct {
|
|||||||
Controller *controller.Controller
|
Controller *controller.Controller
|
||||||
FetchManagers []fetcher.FetcherManager
|
FetchManagers []fetcher.FetcherManager
|
||||||
|
|
||||||
RefreshInterval int // RefreshInterval time duration to refresh task progress(ms)
|
RefreshInterval int `json:"refreshInterval"` // RefreshInterval time duration to refresh task progress(ms)
|
||||||
Storage Storage
|
Storage Storage
|
||||||
StorageDir string
|
StorageDir string
|
||||||
WhiteDownloadDirs []string
|
DownloadDirWhiteList []string `json:"downloadDirWhiteList"`
|
||||||
|
|
||||||
ProductionMode bool
|
ProductionMode bool
|
||||||
|
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
package download
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ScriptEvent represents the type of script event
|
|
||||||
type ScriptEvent string
|
|
||||||
|
|
||||||
const (
|
|
||||||
ScriptEventDownloadDone ScriptEvent = "DOWNLOAD_DONE"
|
|
||||||
ScriptEventDownloadError ScriptEvent = "DOWNLOAD_ERROR"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ScriptData is the internal data structure for passing script information
|
|
||||||
type ScriptData struct {
|
|
||||||
Event ScriptEvent
|
|
||||||
Time int64 // Unix timestamp in milliseconds
|
|
||||||
Payload *ScriptPayload
|
|
||||||
}
|
|
||||||
|
|
||||||
// ScriptPayload contains the task data
|
|
||||||
type ScriptPayload struct {
|
|
||||||
Task *Task
|
|
||||||
}
|
|
||||||
|
|
||||||
// getScriptPaths extracts script paths from config
|
|
||||||
func (d *Downloader) getScriptPaths() []string {
|
|
||||||
cfg := d.cfg.DownloaderStoreConfig
|
|
||||||
if cfg == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check new script config
|
|
||||||
if cfg.Script != nil && cfg.Script.Enable && len(cfg.Script.Paths) > 0 {
|
|
||||||
paths := make([]string, 0, len(cfg.Script.Paths))
|
|
||||||
for _, path := range cfg.Script.Paths {
|
|
||||||
if path != "" {
|
|
||||||
paths = append(paths, path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(paths) > 0 {
|
|
||||||
return paths
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// executeScriptAtPath executes a single script with the given data
|
|
||||||
// Returns any error that occurred during execution
|
|
||||||
func (d *Downloader) executeScriptAtPath(scriptPath string, data *ScriptData) error {
|
|
||||||
if scriptPath == "" {
|
|
||||||
return fmt.Errorf("script path is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if script file exists
|
|
||||||
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("script file does not exist: %s", scriptPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine the script interpreter based on file extension
|
|
||||||
var cmd *exec.Cmd
|
|
||||||
ext := filepath.Ext(scriptPath)
|
|
||||||
|
|
||||||
switch ext {
|
|
||||||
case ".sh", ".bash":
|
|
||||||
cmd = exec.Command("bash", scriptPath)
|
|
||||||
case ".py":
|
|
||||||
cmd = exec.Command("python3", scriptPath)
|
|
||||||
case ".js":
|
|
||||||
cmd = exec.Command("node", scriptPath)
|
|
||||||
case ".bat", ".cmd":
|
|
||||||
// Windows batch files
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
cmd = exec.Command("cmd", "/c", scriptPath)
|
|
||||||
} else {
|
|
||||||
// Batch files are Windows-specific
|
|
||||||
return fmt.Errorf("batch files (.bat/.cmd) are only supported on Windows")
|
|
||||||
}
|
|
||||||
case ".ps1":
|
|
||||||
// PowerShell scripts
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
cmd = exec.Command("powershell", "-ExecutionPolicy", "Bypass", "-File", scriptPath)
|
|
||||||
} else {
|
|
||||||
// Try pwsh (PowerShell Core) on non-Windows systems
|
|
||||||
cmd = exec.Command("pwsh", "-File", scriptPath)
|
|
||||||
}
|
|
||||||
case "":
|
|
||||||
// No extension, try to execute directly (assumes shebang or executable)
|
|
||||||
cmd = exec.Command(scriptPath)
|
|
||||||
default:
|
|
||||||
// Unknown extension, try to execute directly
|
|
||||||
cmd = exec.Command(scriptPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set environment variables with task information
|
|
||||||
cmd.Env = append(os.Environ(),
|
|
||||||
fmt.Sprintf("GOPEED_EVENT=%s", data.Event),
|
|
||||||
fmt.Sprintf("GOPEED_TASK_ID=%s", data.Payload.Task.ID),
|
|
||||||
fmt.Sprintf("GOPEED_TASK_NAME=%s", data.Payload.Task.Name()),
|
|
||||||
fmt.Sprintf("GOPEED_TASK_STATUS=%s", data.Payload.Task.Status),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Add task path using the same logic as task deletion
|
|
||||||
if data.Payload.Task.Meta != nil && data.Payload.Task.Meta.Res != nil {
|
|
||||||
var taskPath string
|
|
||||||
if data.Payload.Task.Meta.Res.Name != "" {
|
|
||||||
// Multi-file task (folder)
|
|
||||||
taskPath = data.Payload.Task.Meta.FolderPath()
|
|
||||||
} else {
|
|
||||||
// Single file task
|
|
||||||
taskPath = data.Payload.Task.Meta.SingleFilepath()
|
|
||||||
}
|
|
||||||
cmd.Env = append(cmd.Env,
|
|
||||||
fmt.Sprintf("GOPEED_TASK_PATH=%s", taskPath),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start and wait for the command to complete (no timeout)
|
|
||||||
return cmd.Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// triggerScripts executes all configured scripts
|
|
||||||
func (d *Downloader) triggerScripts(event ScriptEvent, task *Task, err error) {
|
|
||||||
paths := d.getScriptPaths()
|
|
||||||
if len(paths) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data := &ScriptData{
|
|
||||||
Event: event,
|
|
||||||
Time: time.Now().UnixMilli(),
|
|
||||||
Payload: &ScriptPayload{
|
|
||||||
Task: task.clone(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
go d.executeScripts(paths, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Downloader) executeScripts(paths []string, data *ScriptData) {
|
|
||||||
for _, path := range paths {
|
|
||||||
if path == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
go func(scriptPath string) {
|
|
||||||
err := d.executeScriptAtPath(scriptPath, data)
|
|
||||||
if err != nil {
|
|
||||||
d.Logger.Warn().Err(err).Str("path", scriptPath).Msg("script: failed to execute")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d.Logger.Debug().Str("path", scriptPath).Msg("script: executed successfully")
|
|
||||||
}(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
package download
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestScript_NoScriptConfigured(t *testing.T) {
|
|
||||||
setupScriptTest(t, func(downloader *Downloader) {
|
|
||||||
// Create a mock task
|
|
||||||
task := NewTask()
|
|
||||||
task.Protocol = "http"
|
|
||||||
task.Meta = &mockFetcherMeta
|
|
||||||
|
|
||||||
// Trigger script (should not panic with no scripts configured)
|
|
||||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScript_GetScriptPaths_EmptyConfig(t *testing.T) {
|
|
||||||
setupScriptTest(t, func(downloader *Downloader) {
|
|
||||||
paths := downloader.getScriptPaths()
|
|
||||||
if paths != nil {
|
|
||||||
t.Errorf("Expected nil, got %v", paths)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScript_GetScriptPaths_NoScriptConfig(t *testing.T) {
|
|
||||||
setupScriptTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Script = nil
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
paths := downloader.getScriptPaths()
|
|
||||||
if paths != nil {
|
|
||||||
t.Errorf("Expected nil, got %v", paths)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScript_GetScriptPaths_DisabledScript(t *testing.T) {
|
|
||||||
setupScriptTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Script = &base.ScriptConfig{
|
|
||||||
Enable: false,
|
|
||||||
Paths: []string{"/path/to/script.sh"},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
paths := downloader.getScriptPaths()
|
|
||||||
if paths != nil {
|
|
||||||
t.Errorf("Expected nil for disabled script, got %v", paths)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScript_GetScriptPaths_EmptyPaths(t *testing.T) {
|
|
||||||
setupScriptTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Script = &base.ScriptConfig{
|
|
||||||
Enable: true,
|
|
||||||
Paths: []string{},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
paths := downloader.getScriptPaths()
|
|
||||||
if paths != nil {
|
|
||||||
t.Errorf("Expected nil for empty paths, got %v", paths)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScript_GetScriptPaths_WithEmptyStrings(t *testing.T) {
|
|
||||||
setupScriptTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Script = &base.ScriptConfig{
|
|
||||||
Enable: true,
|
|
||||||
Paths: []string{"/path/to/script1.sh", "", "/path/to/script2.sh", ""},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
paths := downloader.getScriptPaths()
|
|
||||||
if len(paths) != 2 {
|
|
||||||
t.Errorf("Expected 2 valid paths (ignoring empty strings), got %d: %v", len(paths), paths)
|
|
||||||
}
|
|
||||||
if paths[0] != "/path/to/script1.sh" || paths[1] != "/path/to/script2.sh" {
|
|
||||||
t.Errorf("Paths don't match expected values: %v", paths)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScript_ExecuteScriptAtPath_EmptyPath(t *testing.T) {
|
|
||||||
setupScriptTest(t, func(downloader *Downloader) {
|
|
||||||
data := &ScriptData{
|
|
||||||
Event: ScriptEventDownloadDone,
|
|
||||||
Time: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := downloader.executeScriptAtPath("", data)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Expected error for empty path")
|
|
||||||
}
|
|
||||||
if err.Error() != "script path is empty" {
|
|
||||||
t.Errorf("Expected 'script path is empty' error, got: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScript_ExecuteScriptAtPath_NonExistentFile(t *testing.T) {
|
|
||||||
setupScriptTest(t, func(downloader *Downloader) {
|
|
||||||
data := &ScriptData{
|
|
||||||
Event: ScriptEventDownloadDone,
|
|
||||||
Time: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
|
|
||||||
err := downloader.executeScriptAtPath("/non/existent/script.sh", data)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Expected error for non-existent script")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func createDownloadDoneTask(t *testing.T, downloadDir, fileName string) (*Task, string) {
|
|
||||||
t.Helper()
|
|
||||||
content := []byte("downloaded file")
|
|
||||||
task := NewTask()
|
|
||||||
task.Protocol = "http"
|
|
||||||
task.Status = base.DownloadStatusDone
|
|
||||||
task.Meta = &fetcher.FetcherMeta{
|
|
||||||
Req: &base.Request{
|
|
||||||
URL: "https://example.com/" + fileName,
|
|
||||||
},
|
|
||||||
Opts: &base.Options{
|
|
||||||
Name: fileName,
|
|
||||||
Path: filepath.ToSlash(downloadDir),
|
|
||||||
},
|
|
||||||
Res: &base.Resource{
|
|
||||||
Size: int64(len(content)),
|
|
||||||
Files: []*base.FileInfo{
|
|
||||||
{Name: fileName, Size: int64(len(content))},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
filePath := task.Meta.SingleFilepath()
|
|
||||||
filePathOS := filepath.FromSlash(filePath)
|
|
||||||
if err := os.MkdirAll(filepath.Dir(filePathOS), 0755); err != nil {
|
|
||||||
t.Fatalf("Failed to create download dir: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filePathOS, content, 0644); err != nil {
|
|
||||||
t.Fatalf("Failed to create download file: %v", err)
|
|
||||||
}
|
|
||||||
return task, filePath
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForFile(t *testing.T, path string, timeout time.Duration) {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(timeout)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if _, err := os.Stat(path); err == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
}
|
|
||||||
t.Fatalf("Timeout waiting for file: %s", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getTestScriptPath(t *testing.T, name string) string {
|
|
||||||
t.Helper()
|
|
||||||
path := filepath.Join("testdata", "scripts", name)
|
|
||||||
if _, err := os.Stat(path); err != nil {
|
|
||||||
t.Fatalf("Missing test script %s: %v", path, err)
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureScriptExecutable(t *testing.T, scriptPath string) {
|
|
||||||
t.Helper()
|
|
||||||
if filepath.Ext(scriptPath) != ".sh" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := os.Chmod(scriptPath, 0755); err != nil {
|
|
||||||
t.Fatalf("Failed to chmod script: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupScriptTest(t *testing.T, fn func(downloader *Downloader)) {
|
|
||||||
defaultDownloader.Setup()
|
|
||||||
defaultDownloader.cfg.StorageDir = ".test_storage"
|
|
||||||
defaultDownloader.cfg.DownloadDir = ".test_download"
|
|
||||||
defer func() {
|
|
||||||
defaultDownloader.Clear()
|
|
||||||
os.RemoveAll(defaultDownloader.cfg.StorageDir)
|
|
||||||
os.RemoveAll(defaultDownloader.cfg.DownloadDir)
|
|
||||||
}()
|
|
||||||
fn(defaultDownloader)
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
// +build !windows
|
|
||||||
|
|
||||||
package download
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestScript_TriggerOnDone_MoveFile(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
downloadDir := filepath.Join(tmpDir, "downloads")
|
|
||||||
destDir := filepath.Join(tmpDir, "moved")
|
|
||||||
task, taskPath := createDownloadDoneTask(t, downloadDir, "test.txt")
|
|
||||||
srcPath := filepath.FromSlash(taskPath)
|
|
||||||
destFile := filepath.Join(destDir, "test.txt")
|
|
||||||
|
|
||||||
scriptPath := getTestScriptPath(t, "move.sh")
|
|
||||||
ensureScriptExecutable(t, scriptPath)
|
|
||||||
|
|
||||||
setupScriptTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Script = &base.ScriptConfig{
|
|
||||||
Enable: true,
|
|
||||||
Paths: []string{scriptPath},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
t.Setenv("GOPEED_TEST_DEST_DIR", destDir)
|
|
||||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
|
||||||
|
|
||||||
waitForFile(t, destFile, 3*time.Second)
|
|
||||||
if _, err := os.Stat(srcPath); !os.IsNotExist(err) {
|
|
||||||
t.Errorf("Expected source file to be moved, but it still exists: %s", srcPath)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScript_MultipleScripts(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
outputFile1 := filepath.Join(tmpDir, "output1.txt")
|
|
||||||
outputFile2 := filepath.Join(tmpDir, "output2.txt")
|
|
||||||
scriptPath1 := getTestScriptPath(t, "write_output1.sh")
|
|
||||||
ensureScriptExecutable(t, scriptPath1)
|
|
||||||
scriptPath2 := getTestScriptPath(t, "write_output2.sh")
|
|
||||||
ensureScriptExecutable(t, scriptPath2)
|
|
||||||
|
|
||||||
setupScriptTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Script = &base.ScriptConfig{
|
|
||||||
Enable: true,
|
|
||||||
Paths: []string{scriptPath1, scriptPath2},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
downloadDir := filepath.Join(tmpDir, "downloads")
|
|
||||||
task, _ := createDownloadDoneTask(t, downloadDir, "multi.txt")
|
|
||||||
|
|
||||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE_1", outputFile1)
|
|
||||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE_2", outputFile2)
|
|
||||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
|
||||||
|
|
||||||
waitForFile(t, outputFile1, 3*time.Second)
|
|
||||||
waitForFile(t, outputFile2, 3*time.Second)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScript_EnvironmentVariables(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
scriptPath := getTestScriptPath(t, "env_dump.sh")
|
|
||||||
ensureScriptExecutable(t, scriptPath)
|
|
||||||
outputFile := filepath.Join(tmpDir, "env_output.txt")
|
|
||||||
|
|
||||||
setupScriptTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Script = &base.ScriptConfig{
|
|
||||||
Enable: true,
|
|
||||||
Paths: []string{scriptPath},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
downloadDir := filepath.Join(tmpDir, "downloads")
|
|
||||||
task, taskPath := createDownloadDoneTask(t, downloadDir, "env.txt")
|
|
||||||
|
|
||||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE", outputFile)
|
|
||||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
|
||||||
|
|
||||||
waitForFile(t, outputFile, 3*time.Second)
|
|
||||||
content, err := os.ReadFile(outputFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read output file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
output := string(content)
|
|
||||||
if !strings.Contains(output, "GOPEED_EVENT=DOWNLOAD_DONE") {
|
|
||||||
t.Errorf("Expected GOPEED_EVENT in output, got: %s", output)
|
|
||||||
}
|
|
||||||
if !strings.Contains(output, "GOPEED_TASK_ID="+task.ID) {
|
|
||||||
t.Errorf("Expected GOPEED_TASK_ID in output, got: %s", output)
|
|
||||||
}
|
|
||||||
if !strings.Contains(output, "GOPEED_TASK_NAME="+task.Name()) {
|
|
||||||
t.Errorf("Expected GOPEED_TASK_NAME in output, got: %s", output)
|
|
||||||
}
|
|
||||||
if !strings.Contains(output, "GOPEED_TASK_STATUS="+string(task.Status)) {
|
|
||||||
t.Errorf("Expected GOPEED_TASK_STATUS in output, got: %s", output)
|
|
||||||
}
|
|
||||||
if !strings.Contains(output, "GOPEED_TASK_PATH="+taskPath) {
|
|
||||||
t.Errorf("Expected GOPEED_TASK_PATH in output, got: %s", output)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
// +build windows
|
|
||||||
|
|
||||||
package download
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestScript_TriggerOnDone_MoveFile(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
downloadDir := filepath.Join(tmpDir, "downloads")
|
|
||||||
destDir := filepath.Join(tmpDir, "moved")
|
|
||||||
task, taskPath := createDownloadDoneTask(t, downloadDir, "test.txt")
|
|
||||||
srcPath := filepath.FromSlash(taskPath)
|
|
||||||
destFile := filepath.Join(destDir, "test.txt")
|
|
||||||
|
|
||||||
scriptPath := getTestScriptPath(t, "move.bat")
|
|
||||||
|
|
||||||
setupScriptTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Script = &base.ScriptConfig{
|
|
||||||
Enable: true,
|
|
||||||
Paths: []string{scriptPath},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
t.Setenv("GOPEED_TEST_DEST_DIR", destDir)
|
|
||||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
|
||||||
|
|
||||||
waitForFile(t, destFile, 5*time.Second)
|
|
||||||
if _, err := os.Stat(srcPath); !os.IsNotExist(err) {
|
|
||||||
t.Errorf("Expected source file to be moved, but it still exists: %s", srcPath)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScript_MultipleScripts(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
outputFile1 := filepath.Join(tmpDir, "output1.txt")
|
|
||||||
outputFile2 := filepath.Join(tmpDir, "output2.txt")
|
|
||||||
scriptPath1 := getTestScriptPath(t, "write_output1.bat")
|
|
||||||
scriptPath2 := getTestScriptPath(t, "write_output2.bat")
|
|
||||||
|
|
||||||
setupScriptTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Script = &base.ScriptConfig{
|
|
||||||
Enable: true,
|
|
||||||
Paths: []string{scriptPath1, scriptPath2},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
downloadDir := filepath.Join(tmpDir, "downloads")
|
|
||||||
task, _ := createDownloadDoneTask(t, downloadDir, "multi.txt")
|
|
||||||
|
|
||||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE_1", outputFile1)
|
|
||||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE_2", outputFile2)
|
|
||||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
|
||||||
|
|
||||||
waitForFile(t, outputFile1, 5*time.Second)
|
|
||||||
waitForFile(t, outputFile2, 5*time.Second)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScript_EnvironmentVariables(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
scriptPath := getTestScriptPath(t, "env_dump.bat")
|
|
||||||
outputFile := filepath.Join(tmpDir, "env_output.txt")
|
|
||||||
|
|
||||||
setupScriptTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Script = &base.ScriptConfig{
|
|
||||||
Enable: true,
|
|
||||||
Paths: []string{scriptPath},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
downloadDir := filepath.Join(tmpDir, "downloads")
|
|
||||||
task, taskPath := createDownloadDoneTask(t, downloadDir, "env.txt")
|
|
||||||
|
|
||||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE", outputFile)
|
|
||||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
|
||||||
|
|
||||||
waitForFile(t, outputFile, 5*time.Second)
|
|
||||||
content, err := os.ReadFile(outputFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read output file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
output := string(content)
|
|
||||||
if !strings.Contains(output, "GOPEED_EVENT=DOWNLOAD_DONE") {
|
|
||||||
t.Errorf("Expected GOPEED_EVENT in output, got: %s", output)
|
|
||||||
}
|
|
||||||
if !strings.Contains(output, "GOPEED_TASK_ID="+task.ID) {
|
|
||||||
t.Errorf("Expected GOPEED_TASK_ID in output, got: %s", output)
|
|
||||||
}
|
|
||||||
if !strings.Contains(output, "GOPEED_TASK_NAME="+task.Name()) {
|
|
||||||
t.Errorf("Expected GOPEED_TASK_NAME in output, got: %s", output)
|
|
||||||
}
|
|
||||||
if !strings.Contains(output, "GOPEED_TASK_STATUS="+string(task.Status)) {
|
|
||||||
t.Errorf("Expected GOPEED_TASK_STATUS in output, got: %s", output)
|
|
||||||
}
|
|
||||||
if !strings.Contains(output, "GOPEED_TASK_PATH="+taskPath) {
|
|
||||||
t.Errorf("Expected GOPEED_TASK_PATH in output, got: %s", output)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+11
-14
@@ -2,12 +2,11 @@ package download
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"go.etcd.io/bbolt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Storage interface {
|
type Storage interface {
|
||||||
@@ -46,24 +45,22 @@ func changeValue(p any, v any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var memData = make(map[string]map[string]any)
|
||||||
|
|
||||||
type MemStorage struct {
|
type MemStorage struct {
|
||||||
lock *sync.RWMutex
|
lock *sync.RWMutex
|
||||||
data map[string]map[string]any
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMemStorage() *MemStorage {
|
func NewMemStorage() *MemStorage {
|
||||||
return &MemStorage{
|
return &MemStorage{
|
||||||
lock: &sync.RWMutex{},
|
lock: &sync.RWMutex{},
|
||||||
data: make(map[string]map[string]any),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *MemStorage) Setup(buckets []string) error {
|
func (n *MemStorage) Setup(buckets []string) error {
|
||||||
n.lock.Lock()
|
|
||||||
defer n.lock.Unlock()
|
|
||||||
for _, bucket := range buckets {
|
for _, bucket := range buckets {
|
||||||
if _, ok := n.data[bucket]; !ok {
|
if _, ok := memData[bucket]; !ok {
|
||||||
n.data[bucket] = make(map[string]any)
|
memData[bucket] = make(map[string]any)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -72,7 +69,7 @@ func (n *MemStorage) Setup(buckets []string) error {
|
|||||||
func (n *MemStorage) Put(bucket string, key string, v any) error {
|
func (n *MemStorage) Put(bucket string, key string, v any) error {
|
||||||
n.lock.Lock()
|
n.lock.Lock()
|
||||||
defer n.lock.Unlock()
|
defer n.lock.Unlock()
|
||||||
if bucketData, ok := n.data[bucket]; ok {
|
if bucketData, ok := memData[bucket]; ok {
|
||||||
bucketData[key] = v
|
bucketData[key] = v
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -81,7 +78,7 @@ func (n *MemStorage) Put(bucket string, key string, v any) error {
|
|||||||
func (n *MemStorage) Get(bucket string, key string, v any) (bool, error) {
|
func (n *MemStorage) Get(bucket string, key string, v any) (bool, error) {
|
||||||
n.lock.RLock()
|
n.lock.RLock()
|
||||||
defer n.lock.RUnlock()
|
defer n.lock.RUnlock()
|
||||||
if dv, ok := n.data[bucket][key]; ok {
|
if dv, ok := memData[bucket][key]; ok {
|
||||||
changeValue(v, dv)
|
changeValue(v, dv)
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
@@ -91,7 +88,7 @@ func (n *MemStorage) Get(bucket string, key string, v any) (bool, error) {
|
|||||||
func (n *MemStorage) List(bucket string, v any) error {
|
func (n *MemStorage) List(bucket string, v any) error {
|
||||||
n.lock.RLock()
|
n.lock.RLock()
|
||||||
defer n.lock.RUnlock()
|
defer n.lock.RUnlock()
|
||||||
data := n.data[bucket]
|
data := memData[bucket]
|
||||||
list := make([]any, 0)
|
list := make([]any, 0)
|
||||||
for _, v := range data {
|
for _, v := range data {
|
||||||
list = append(list, v)
|
list = append(list, v)
|
||||||
@@ -103,7 +100,7 @@ func (n *MemStorage) List(bucket string, v any) error {
|
|||||||
func (n *MemStorage) Pop(bucket string, key string, v any) error {
|
func (n *MemStorage) Pop(bucket string, key string, v any) error {
|
||||||
n.lock.Lock()
|
n.lock.Lock()
|
||||||
defer n.lock.Unlock()
|
defer n.lock.Unlock()
|
||||||
data := n.data[bucket]
|
data := memData[bucket]
|
||||||
changeValue(v, data[key])
|
changeValue(v, data[key])
|
||||||
delete(data, key)
|
delete(data, key)
|
||||||
return nil
|
return nil
|
||||||
@@ -112,7 +109,7 @@ func (n *MemStorage) Pop(bucket string, key string, v any) error {
|
|||||||
func (n *MemStorage) Delete(bucket string, key string) error {
|
func (n *MemStorage) Delete(bucket string, key string) error {
|
||||||
n.lock.Lock()
|
n.lock.Lock()
|
||||||
defer n.lock.Unlock()
|
defer n.lock.Unlock()
|
||||||
delete(n.data[bucket], key)
|
delete(memData[bucket], key)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +120,7 @@ func (n *MemStorage) Close() error {
|
|||||||
func (n *MemStorage) Clear() error {
|
func (n *MemStorage) Clear() error {
|
||||||
n.lock.Lock()
|
n.lock.Lock()
|
||||||
defer n.lock.Unlock()
|
defer n.lock.Unlock()
|
||||||
n.data = make(map[string]map[string]any)
|
memData = make(map[string]map[string]any)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-9
@@ -1,9 +0,0 @@
|
|||||||
@echo off
|
|
||||||
setlocal
|
|
||||||
|
|
||||||
if "%GOPEED_TEST_OUTPUT_FILE%"=="" exit /b 2
|
|
||||||
echo GOPEED_EVENT=%GOPEED_EVENT% > "%GOPEED_TEST_OUTPUT_FILE%"
|
|
||||||
echo GOPEED_TASK_ID=%GOPEED_TASK_ID% >> "%GOPEED_TEST_OUTPUT_FILE%"
|
|
||||||
echo GOPEED_TASK_NAME=%GOPEED_TASK_NAME% >> "%GOPEED_TEST_OUTPUT_FILE%"
|
|
||||||
echo GOPEED_TASK_STATUS=%GOPEED_TASK_STATUS% >> "%GOPEED_TEST_OUTPUT_FILE%"
|
|
||||||
echo GOPEED_TASK_PATH=%GOPEED_TASK_PATH% >> "%GOPEED_TEST_OUTPUT_FILE%"
|
|
||||||
-13
@@ -1,13 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [ -z "$GOPEED_TEST_OUTPUT_FILE" ]; then
|
|
||||||
echo "GOPEED_TEST_OUTPUT_FILE is empty" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "GOPEED_EVENT=$GOPEED_EVENT" > "$GOPEED_TEST_OUTPUT_FILE"
|
|
||||||
echo "GOPEED_TASK_ID=$GOPEED_TASK_ID" >> "$GOPEED_TEST_OUTPUT_FILE"
|
|
||||||
echo "GOPEED_TASK_NAME=$GOPEED_TASK_NAME" >> "$GOPEED_TEST_OUTPUT_FILE"
|
|
||||||
echo "GOPEED_TASK_STATUS=$GOPEED_TASK_STATUS" >> "$GOPEED_TEST_OUTPUT_FILE"
|
|
||||||
echo "GOPEED_TASK_PATH=$GOPEED_TASK_PATH" >> "$GOPEED_TEST_OUTPUT_FILE"
|
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
@echo off
|
|
||||||
setlocal
|
|
||||||
|
|
||||||
if "%GOPEED_TEST_DEST_DIR%"=="" exit /b 2
|
|
||||||
set "SRC=%GOPEED_TASK_PATH:/=\%"
|
|
||||||
set "DEST=%GOPEED_TEST_DEST_DIR%"
|
|
||||||
if not exist "%DEST%" mkdir "%DEST%"
|
|
||||||
move /Y "%SRC%" "%DEST%\" >nul
|
|
||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [ -z "$GOPEED_TEST_DEST_DIR" ]; then
|
|
||||||
echo "GOPEED_TEST_DEST_DIR is empty" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p "$GOPEED_TEST_DEST_DIR"
|
|
||||||
mv "$GOPEED_TASK_PATH" "$GOPEED_TEST_DEST_DIR/"
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
@echo off
|
|
||||||
setlocal
|
|
||||||
|
|
||||||
if "%GOPEED_TEST_OUTPUT_FILE_1%"=="" exit /b 2
|
|
||||||
echo Script 1 > "%GOPEED_TEST_OUTPUT_FILE_1%"
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [ -z "$GOPEED_TEST_OUTPUT_FILE_1" ]; then
|
|
||||||
echo "GOPEED_TEST_OUTPUT_FILE_1 is empty" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Script 1" > "$GOPEED_TEST_OUTPUT_FILE_1"
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
@echo off
|
|
||||||
setlocal
|
|
||||||
|
|
||||||
if "%GOPEED_TEST_OUTPUT_FILE_2%"=="" exit /b 2
|
|
||||||
echo Script 2 > "%GOPEED_TEST_OUTPUT_FILE_2%"
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
if [ -z "$GOPEED_TEST_OUTPUT_FILE_2" ]; then
|
|
||||||
echo "GOPEED_TEST_OUTPUT_FILE_2 is empty" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Script 2" > "$GOPEED_TEST_OUTPUT_FILE_2"
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
package download
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
webhookTimeout = 10 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// WebhookEvent represents the type of webhook event
|
|
||||||
type WebhookEvent string
|
|
||||||
|
|
||||||
const (
|
|
||||||
WebhookEventDownloadDone WebhookEvent = "DOWNLOAD_DONE"
|
|
||||||
WebhookEventDownloadError WebhookEvent = "DOWNLOAD_ERROR"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WebhookData is the data sent to webhook URLs
|
|
||||||
type WebhookData struct {
|
|
||||||
Event WebhookEvent `json:"event"`
|
|
||||||
Time int64 `json:"time"` // Unix timestamp in milliseconds
|
|
||||||
Payload *WebhookPayload `json:"payload"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebhookPayload contains the task data
|
|
||||||
type WebhookPayload struct {
|
|
||||||
Task *Task `json:"task"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// getWebhookUrls extracts webhook URLs from config
|
|
||||||
// Supports both new webhook config format and legacy extra field for backward compatibility
|
|
||||||
func (d *Downloader) getWebhookUrls() []string {
|
|
||||||
cfg := d.cfg.DownloaderStoreConfig
|
|
||||||
if cfg == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try new webhook config first
|
|
||||||
if cfg.Webhook != nil && cfg.Webhook.Enable && len(cfg.Webhook.URLs) > 0 {
|
|
||||||
urls := make([]string, 0, len(cfg.Webhook.URLs))
|
|
||||||
for _, url := range cfg.Webhook.URLs {
|
|
||||||
if url != "" {
|
|
||||||
urls = append(urls, url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(urls) > 0 {
|
|
||||||
return urls
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to legacy extra field for backward compatibility
|
|
||||||
if cfg.Extra == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
webhookUrls, ok := cfg.Extra["webhookUrls"]
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try direct string slice first
|
|
||||||
if urlsStr, ok := webhookUrls.([]string); ok {
|
|
||||||
if len(urlsStr) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return urlsStr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert []interface{} to []string
|
|
||||||
if urlsInterface, ok := webhookUrls.([]any); ok {
|
|
||||||
if len(urlsInterface) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
urls := make([]string, 0, len(urlsInterface))
|
|
||||||
for _, urlInterface := range urlsInterface {
|
|
||||||
if url, ok := urlInterface.(string); ok && url != "" {
|
|
||||||
urls = append(urls, url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(urls) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return urls
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendWebhookToUrl sends webhook data to a single URL
|
|
||||||
// Returns the HTTP status code and any error that occurred
|
|
||||||
func (d *Downloader) sendWebhookToUrl(url string, data *WebhookData) (int, error) {
|
|
||||||
if url == "" {
|
|
||||||
return 0, fmt.Errorf("webhook URL is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonData, err := json.Marshal(data)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: webhookTimeout,
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData))
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("User-Agent", "Gopeed-Webhook/1.0")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
return resp.StatusCode, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// triggerWebhooks sends webhook notifications to all configured URLs
|
|
||||||
func (d *Downloader) triggerWebhooks(event WebhookEvent, task *Task, err error) {
|
|
||||||
urls := d.getWebhookUrls()
|
|
||||||
if len(urls) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data := &WebhookData{
|
|
||||||
Event: event,
|
|
||||||
Time: time.Now().UnixMilli(),
|
|
||||||
Payload: &WebhookPayload{
|
|
||||||
Task: task.clone(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
go d.sendWebhooks(urls, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Downloader) sendWebhooks(urls []string, data *WebhookData) {
|
|
||||||
for _, url := range urls {
|
|
||||||
if url == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
go func(webhookUrl string) {
|
|
||||||
statusCode, err := d.sendWebhookToUrl(webhookUrl, data)
|
|
||||||
if err != nil {
|
|
||||||
d.Logger.Warn().Err(err).Str("url", webhookUrl).Msg("webhook: failed to send request")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if statusCode >= 200 && statusCode < 300 {
|
|
||||||
d.Logger.Debug().Str("url", webhookUrl).Int("status", statusCode).Msg("webhook: sent successfully")
|
|
||||||
} else {
|
|
||||||
d.Logger.Warn().Str("url", webhookUrl).Int("status", statusCode).Msg("webhook: received non-success status")
|
|
||||||
}
|
|
||||||
}(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendTestWebhook sends a test webhook with a simulated payload
|
|
||||||
// Returns error if any webhook URL does not respond with HTTP 200
|
|
||||||
func (d *Downloader) SendTestWebhook() error {
|
|
||||||
urls := d.getWebhookUrls()
|
|
||||||
if len(urls) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, url := range urls {
|
|
||||||
if url == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := d.TestWebhookUrl(url); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookUrl tests a single webhook URL with a simulated payload
|
|
||||||
// Returns error if the URL does not respond with HTTP 200
|
|
||||||
func (d *Downloader) TestWebhookUrl(url string) error {
|
|
||||||
// Create a simulated test task with minimal required fields
|
|
||||||
testTask := NewTask()
|
|
||||||
testTask.Protocol = "http"
|
|
||||||
testTask.Status = base.DownloadStatusDone
|
|
||||||
testTask.Meta = &fetcher.FetcherMeta{
|
|
||||||
Req: &base.Request{
|
|
||||||
URL: "https://example.com/test-file.zip",
|
|
||||||
},
|
|
||||||
Opts: &base.Options{
|
|
||||||
Name: "test-file.zip",
|
|
||||||
Path: "/downloads",
|
|
||||||
},
|
|
||||||
Res: &base.Resource{
|
|
||||||
Size: 1024 * 1024 * 100, // 100MB
|
|
||||||
Files: []*base.FileInfo{
|
|
||||||
{Name: "test-file.zip", Size: 1024 * 1024 * 100},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create test data
|
|
||||||
testData := &WebhookData{
|
|
||||||
Event: WebhookEventDownloadDone,
|
|
||||||
Time: time.Now().UnixMilli(),
|
|
||||||
Payload: &WebhookPayload{
|
|
||||||
Task: testTask,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
statusCode, err := d.sendWebhookToUrl(url, testData)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if statusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("webhook test failed: %s returned status %d", url, statusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,827 +0,0 @@
|
|||||||
package download
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/internal/fetcher"
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
|
||||||
)
|
|
||||||
|
|
||||||
var mockFetcherMeta = fetcher.FetcherMeta{
|
|
||||||
Req: &base.Request{
|
|
||||||
URL: "https://example.com/test.zip",
|
|
||||||
},
|
|
||||||
Opts: &base.Options{
|
|
||||||
Path: "/downloads",
|
|
||||||
},
|
|
||||||
Res: &base.Resource{
|
|
||||||
Size: 1024 * 1024,
|
|
||||||
Files: []*base.FileInfo{
|
|
||||||
{Name: "test.zip", Size: 1024 * 1024},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_TriggerOnDone(t *testing.T) {
|
|
||||||
receivedData := make(chan *WebhookData, 1)
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
t.Errorf("Expected POST request, got %s", r.Method)
|
|
||||||
}
|
|
||||||
if r.Header.Get("Content-Type") != "application/json" {
|
|
||||||
t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
|
|
||||||
}
|
|
||||||
var data WebhookData
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
|
||||||
t.Errorf("Failed to decode data: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
receivedData <- &data
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
// Configure webhook URLs
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Webhook = &base.WebhookConfig{
|
|
||||||
Enable: true,
|
|
||||||
URLs: []string{server.URL},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
// Create a mock task
|
|
||||||
task := NewTask()
|
|
||||||
task.Protocol = "http"
|
|
||||||
task.Meta = &mockFetcherMeta
|
|
||||||
|
|
||||||
// Trigger webhook
|
|
||||||
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case data := <-receivedData:
|
|
||||||
if data.Event != WebhookEventDownloadDone {
|
|
||||||
t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event)
|
|
||||||
}
|
|
||||||
if data.Payload == nil || data.Payload.Task == nil {
|
|
||||||
t.Error("Expected payload.task to be present")
|
|
||||||
} else if data.Payload.Task.ID != task.ID {
|
|
||||||
t.Errorf("Expected task ID '%s', got '%s'", task.ID, data.Payload.Task.ID)
|
|
||||||
}
|
|
||||||
if data.Time == 0 {
|
|
||||||
t.Error("Expected time to be set")
|
|
||||||
}
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Error("Timeout waiting for webhook")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_TriggerOnError(t *testing.T) {
|
|
||||||
receivedData := make(chan *WebhookData, 1)
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var data WebhookData
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
|
||||||
t.Errorf("Failed to decode data: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
receivedData <- &data
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
// Configure webhook URLs
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Webhook = &base.WebhookConfig{
|
|
||||||
Enable: true,
|
|
||||||
URLs: []string{server.URL},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
// Create a mock task
|
|
||||||
task := NewTask()
|
|
||||||
task.Protocol = "http"
|
|
||||||
task.Meta = &mockFetcherMeta
|
|
||||||
|
|
||||||
// Trigger webhook with error
|
|
||||||
testError := http.ErrServerClosed
|
|
||||||
downloader.triggerWebhooks(WebhookEventDownloadError, task, testError)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case data := <-receivedData:
|
|
||||||
if data.Event != WebhookEventDownloadError {
|
|
||||||
t.Errorf("Expected event 'DOWNLOAD_ERROR', got '%s'", data.Event)
|
|
||||||
}
|
|
||||||
if data.Payload == nil || data.Payload.Task == nil {
|
|
||||||
t.Error("Expected payload.task to be present")
|
|
||||||
}
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Error("Timeout waiting for webhook")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_SendTestWebhook(t *testing.T) {
|
|
||||||
receivedData := make(chan *WebhookData, 1)
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var data WebhookData
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
|
||||||
t.Errorf("Failed to decode data: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
receivedData <- &data
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
// Configure webhook URLs
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Webhook = &base.WebhookConfig{
|
|
||||||
Enable: true,
|
|
||||||
URLs: []string{server.URL},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
// Send test webhook
|
|
||||||
err := downloader.SendTestWebhook()
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("SendTestWebhook failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case data := <-receivedData:
|
|
||||||
if data.Event != WebhookEventDownloadDone {
|
|
||||||
t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event)
|
|
||||||
}
|
|
||||||
if data.Payload == nil || data.Payload.Task == nil {
|
|
||||||
t.Error("Expected payload.task to be present")
|
|
||||||
}
|
|
||||||
if data.Time == 0 {
|
|
||||||
t.Error("Expected time to be set")
|
|
||||||
}
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Error("Timeout waiting for webhook")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_NoWebhookConfigured(t *testing.T) {
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
// Create a mock task
|
|
||||||
task := NewTask()
|
|
||||||
task.Protocol = "http"
|
|
||||||
task.Meta = &mockFetcherMeta
|
|
||||||
|
|
||||||
// Trigger webhook (should not panic with no webhooks configured)
|
|
||||||
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
|
||||||
|
|
||||||
// Send test webhook (should not panic)
|
|
||||||
err := downloader.SendTestWebhook()
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("SendTestWebhook failed: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_MultipleUrls(t *testing.T) {
|
|
||||||
count := 0
|
|
||||||
server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
count++
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer server1.Close()
|
|
||||||
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
count++
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer server2.Close()
|
|
||||||
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
// Configure multiple webhook URLs
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Webhook = &base.WebhookConfig{
|
|
||||||
Enable: true,
|
|
||||||
URLs: []string{server1.URL, server2.URL},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
// Create a mock task
|
|
||||||
task := NewTask()
|
|
||||||
task.Protocol = "http"
|
|
||||||
task.Meta = &mockFetcherMeta
|
|
||||||
|
|
||||||
// Trigger webhook
|
|
||||||
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
|
||||||
|
|
||||||
// Wait for webhooks
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
|
|
||||||
if count != 2 {
|
|
||||||
t.Errorf("Expected 2 webhook calls, got %d", count)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_TestWebhookFailsOnNon200(t *testing.T) {
|
|
||||||
// Test that SendTestWebhook returns error for non-200 status codes
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError) // 500
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
// Configure webhook URLs
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Webhook = &base.WebhookConfig{
|
|
||||||
Enable: true,
|
|
||||||
URLs: []string{server.URL},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
// Send test webhook - should fail with non-200 status
|
|
||||||
err := downloader.SendTestWebhook()
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Expected SendTestWebhook to return error for non-200 status")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_TestWebhookFailsOn201(t *testing.T) {
|
|
||||||
// Test that SendTestWebhook returns error for 201 (only 200 is success)
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusCreated) // 201
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
// Configure webhook URLs
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Webhook = &base.WebhookConfig{
|
|
||||||
Enable: true,
|
|
||||||
URLs: []string{server.URL},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
// Send test webhook - should fail with 201 status (only 200 is success)
|
|
||||||
err := downloader.SendTestWebhook()
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Expected SendTestWebhook to return error for 201 status")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_TestWebhookUrl(t *testing.T) {
|
|
||||||
receivedData := make(chan *WebhookData, 1)
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var data WebhookData
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
|
||||||
t.Errorf("Failed to decode data: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
receivedData <- &data
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
// Test single URL
|
|
||||||
err := downloader.TestWebhookUrl(server.URL)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("TestWebhookUrl failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case data := <-receivedData:
|
|
||||||
if data.Event != WebhookEventDownloadDone {
|
|
||||||
t.Errorf("Expected event 'DOWNLOAD_DONE', got '%s'", data.Event)
|
|
||||||
}
|
|
||||||
if data.Payload == nil || data.Payload.Task == nil {
|
|
||||||
t.Error("Expected payload.task to be present")
|
|
||||||
}
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Error("Timeout waiting for webhook")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_TestWebhookUrlEmpty(t *testing.T) {
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
// Test with empty URL - should return error
|
|
||||||
err := downloader.TestWebhookUrl("")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Expected TestWebhookUrl to return error for empty URL")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_GetWebhookUrls_EmptyConfig(t *testing.T) {
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
urls := downloader.getWebhookUrls()
|
|
||||||
if urls != nil {
|
|
||||||
t.Errorf("Expected nil, got %v", urls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_GetWebhookUrls_NoExtraField(t *testing.T) {
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Webhook = nil
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
urls := downloader.getWebhookUrls()
|
|
||||||
if urls != nil {
|
|
||||||
t.Errorf("Expected nil, got %v", urls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_GetWebhookUrls_NoWebhookUrlsKey(t *testing.T) {
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Webhook = &base.WebhookConfig{Enable: true} // No URLs set
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
urls := downloader.getWebhookUrls()
|
|
||||||
if urls != nil {
|
|
||||||
t.Errorf("Expected nil, got %v", urls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_GetWebhookUrls_StringSlice(t *testing.T) {
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Webhook = &base.WebhookConfig{
|
|
||||||
Enable: true,
|
|
||||||
URLs: []string{"http://example.com", "http://example2.com"},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
urls := downloader.getWebhookUrls()
|
|
||||||
if len(urls) != 2 {
|
|
||||||
t.Errorf("Expected 2 URLs, got %d", len(urls))
|
|
||||||
}
|
|
||||||
if urls[0] != "http://example.com" || urls[1] != "http://example2.com" {
|
|
||||||
t.Errorf("URLs don't match expected values: %v", urls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_GetWebhookUrls_InterfaceSlice(t *testing.T) {
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Webhook = &base.WebhookConfig{
|
|
||||||
Enable: true,
|
|
||||||
URLs: []string{"http://example.com", "http://example2.com"},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
urls := downloader.getWebhookUrls()
|
|
||||||
if len(urls) != 2 {
|
|
||||||
t.Errorf("Expected 2 URLs, got %d", len(urls))
|
|
||||||
}
|
|
||||||
if urls[0] != "http://example.com" || urls[1] != "http://example2.com" {
|
|
||||||
t.Errorf("URLs don't match expected values: %v", urls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_GetWebhookUrls_EmptyStringSlice(t *testing.T) {
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Extra = make(map[string]any)
|
|
||||||
cfg.Webhook.URLs = []string{}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
urls := downloader.getWebhookUrls()
|
|
||||||
if urls != nil {
|
|
||||||
t.Errorf("Expected nil, got %v", urls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_GetWebhookUrls_InterfaceSliceWithEmptyStrings(t *testing.T) {
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Webhook = &base.WebhookConfig{
|
|
||||||
Enable: true,
|
|
||||||
URLs: []string{"", "", ""},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
urls := downloader.getWebhookUrls()
|
|
||||||
if urls != nil {
|
|
||||||
t.Errorf("Expected nil for all empty strings, got %v", urls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_GetWebhookUrls_InterfaceSliceMixedTypes(t *testing.T) {
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Webhook = &base.WebhookConfig{
|
|
||||||
Enable: true,
|
|
||||||
URLs: []string{"http://example.com", "", "http://example2.com", ""},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
urls := downloader.getWebhookUrls()
|
|
||||||
if len(urls) != 2 {
|
|
||||||
t.Errorf("Expected 2 valid URLs (ignoring empty strings), got %d: %v", len(urls), urls)
|
|
||||||
}
|
|
||||||
if urls[0] != "http://example.com" || urls[1] != "http://example2.com" {
|
|
||||||
t.Errorf("URLs don't match expected values: %v", urls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_GetWebhookUrls_InvalidType(t *testing.T) {
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Webhook = &base.WebhookConfig{Enable: false} // Disabled webhook
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
urls := downloader.getWebhookUrls()
|
|
||||||
if urls != nil {
|
|
||||||
t.Errorf("Expected nil for disabled webhook, got %v", urls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_GetWebhookUrls_DisabledWebhook(t *testing.T) {
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Webhook = &base.WebhookConfig{
|
|
||||||
Enable: false,
|
|
||||||
URLs: []string{"http://example.com"},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
urls := downloader.getWebhookUrls()
|
|
||||||
if urls != nil {
|
|
||||||
t.Errorf("Expected nil for disabled webhook even with URLs, got %v", urls)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_SendWebhookToUrl_Success(t *testing.T) {
|
|
||||||
receivedData := make(chan *WebhookData, 1)
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Verify headers
|
|
||||||
if r.Header.Get("Content-Type") != "application/json" {
|
|
||||||
t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
|
|
||||||
}
|
|
||||||
if r.Header.Get("User-Agent") != "Gopeed-Webhook/1.0" {
|
|
||||||
t.Errorf("Expected User-Agent Gopeed-Webhook/1.0, got %s", r.Header.Get("User-Agent"))
|
|
||||||
}
|
|
||||||
|
|
||||||
var data WebhookData
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
|
||||||
t.Errorf("Failed to decode data: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
receivedData <- &data
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
task := NewTask()
|
|
||||||
task.Protocol = "http"
|
|
||||||
task.Meta = &mockFetcherMeta
|
|
||||||
|
|
||||||
data := &WebhookData{
|
|
||||||
Event: WebhookEventDownloadDone,
|
|
||||||
Time: time.Now().UnixMilli(),
|
|
||||||
Payload: &WebhookPayload{
|
|
||||||
Task: task,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
statusCode, err := downloader.sendWebhookToUrl(server.URL, data)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("sendWebhookToUrl failed: %v", err)
|
|
||||||
}
|
|
||||||
if statusCode != http.StatusOK {
|
|
||||||
t.Errorf("Expected status 200, got %d", statusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case received := <-receivedData:
|
|
||||||
if received.Event != WebhookEventDownloadDone {
|
|
||||||
t.Errorf("Expected event DOWNLOAD_DONE, got %s", received.Event)
|
|
||||||
}
|
|
||||||
case <-time.After(1 * time.Second):
|
|
||||||
t.Error("Timeout waiting for webhook")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_SendWebhookToUrl_EmptyUrl(t *testing.T) {
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
data := &WebhookData{
|
|
||||||
Event: WebhookEventDownloadDone,
|
|
||||||
Time: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := downloader.sendWebhookToUrl("", data)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Expected error for empty URL")
|
|
||||||
}
|
|
||||||
if err.Error() != "webhook URL is empty" {
|
|
||||||
t.Errorf("Expected 'webhook URL is empty' error, got: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_SendWebhookToUrl_InvalidUrl(t *testing.T) {
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
data := &WebhookData{
|
|
||||||
Event: WebhookEventDownloadDone,
|
|
||||||
Time: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := downloader.sendWebhookToUrl("://invalid-url", data)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Expected error for invalid URL")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_SendWebhookToUrl_NonExistentHost(t *testing.T) {
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
data := &WebhookData{
|
|
||||||
Event: WebhookEventDownloadDone,
|
|
||||||
Time: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := downloader.sendWebhookToUrl("http://non-existent-host-12345.example", data)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Expected error for non-existent host")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_SendWebhookToUrl_Timeout(t *testing.T) {
|
|
||||||
// Create a server that delays response beyond webhook timeout
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
time.Sleep(15 * time.Second) // Longer than webhookTimeout (10s)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
data := &WebhookData{
|
|
||||||
Event: WebhookEventDownloadDone,
|
|
||||||
Time: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := downloader.sendWebhookToUrl(server.URL, data)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Expected timeout error")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_SendWebhookToUrl_VariousStatusCodes(t *testing.T) {
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
statusCode int
|
|
||||||
}{
|
|
||||||
{"200 OK", http.StatusOK},
|
|
||||||
{"201 Created", http.StatusCreated},
|
|
||||||
{"204 No Content", http.StatusNoContent},
|
|
||||||
{"400 Bad Request", http.StatusBadRequest},
|
|
||||||
{"404 Not Found", http.StatusNotFound},
|
|
||||||
{"500 Internal Server Error", http.StatusInternalServerError},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(tc.statusCode)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
data := &WebhookData{
|
|
||||||
Event: WebhookEventDownloadDone,
|
|
||||||
Time: time.Now().UnixMilli(),
|
|
||||||
}
|
|
||||||
|
|
||||||
statusCode, err := downloader.sendWebhookToUrl(server.URL, data)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("sendWebhookToUrl failed: %v", err)
|
|
||||||
}
|
|
||||||
if statusCode != tc.statusCode {
|
|
||||||
t.Errorf("Expected status %d, got %d", tc.statusCode, statusCode)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_TriggerWebhooks_EmptyUrlSkipped(t *testing.T) {
|
|
||||||
requestCount := 0
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
requestCount++
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Webhook = &base.WebhookConfig{
|
|
||||||
Enable: true,
|
|
||||||
URLs: []string{server.URL, "", server.URL},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
task := NewTask()
|
|
||||||
task.Protocol = "http"
|
|
||||||
task.Meta = &mockFetcherMeta
|
|
||||||
|
|
||||||
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
|
||||||
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
|
|
||||||
if requestCount != 2 {
|
|
||||||
t.Errorf("Expected 2 requests (empty URL should be skipped), got %d", requestCount)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_WebhookDataStructure(t *testing.T) {
|
|
||||||
receivedData := make(chan *WebhookData, 1)
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var data WebhookData
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
|
||||||
t.Errorf("Failed to decode data: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
receivedData <- &data
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Webhook = &base.WebhookConfig{
|
|
||||||
Enable: true,
|
|
||||||
URLs: []string{server.URL},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
task := NewTask()
|
|
||||||
task.Protocol = "http"
|
|
||||||
task.Status = base.DownloadStatusDone
|
|
||||||
task.Meta = &mockFetcherMeta
|
|
||||||
|
|
||||||
downloader.triggerWebhooks(WebhookEventDownloadDone, task, nil)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case data := <-receivedData:
|
|
||||||
// Verify event
|
|
||||||
if data.Event != WebhookEventDownloadDone {
|
|
||||||
t.Errorf("Expected event DOWNLOAD_DONE, got %s", data.Event)
|
|
||||||
}
|
|
||||||
// Verify time is set
|
|
||||||
if data.Time == 0 {
|
|
||||||
t.Error("Expected time to be set")
|
|
||||||
}
|
|
||||||
// Verify payload
|
|
||||||
if data.Payload == nil {
|
|
||||||
t.Error("Expected payload to be present")
|
|
||||||
}
|
|
||||||
if data.Payload.Task == nil {
|
|
||||||
t.Error("Expected task in payload to be present")
|
|
||||||
}
|
|
||||||
// Verify task is cloned (has same ID but different pointer)
|
|
||||||
if data.Payload.Task.ID != task.ID {
|
|
||||||
t.Errorf("Expected task ID %s, got %s", task.ID, data.Payload.Task.ID)
|
|
||||||
}
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Error("Timeout waiting for webhook")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_SendTestWebhook_EmptyUrls(t *testing.T) {
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Extra = make(map[string]any)
|
|
||||||
cfg.Webhook.URLs = []string{}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
err := downloader.SendTestWebhook()
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Expected no error for empty URLs, got: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_SendTestWebhook_MixedResults(t *testing.T) {
|
|
||||||
// First server returns 200
|
|
||||||
server1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer server1.Close()
|
|
||||||
|
|
||||||
// Second server returns 500
|
|
||||||
server2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
}))
|
|
||||||
defer server2.Close()
|
|
||||||
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
cfg, _ := downloader.GetConfig()
|
|
||||||
cfg.Webhook = &base.WebhookConfig{
|
|
||||||
Enable: true,
|
|
||||||
URLs: []string{server1.URL, server2.URL},
|
|
||||||
}
|
|
||||||
downloader.PutConfig(cfg)
|
|
||||||
|
|
||||||
// Should fail because server2 returns 500
|
|
||||||
err := downloader.SendTestWebhook()
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Expected error when one server returns non-200 status")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_TestWebhookUrl_InvalidUrl(t *testing.T) {
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
err := downloader.TestWebhookUrl("://invalid")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Expected error for invalid URL")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWebhook_TestWebhookUrl_VerifyTestPayload(t *testing.T) {
|
|
||||||
receivedData := make(chan *WebhookData, 1)
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var data WebhookData
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
|
||||||
t.Errorf("Failed to decode data: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
receivedData <- &data
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
setupWebhookTest(t, func(downloader *Downloader) {
|
|
||||||
err := downloader.TestWebhookUrl(server.URL)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("TestWebhookUrl failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case data := <-receivedData:
|
|
||||||
// Verify it's a test webhook
|
|
||||||
if data.Event != WebhookEventDownloadDone {
|
|
||||||
t.Errorf("Expected event DOWNLOAD_DONE, got %s", data.Event)
|
|
||||||
}
|
|
||||||
if data.Payload == nil || data.Payload.Task == nil {
|
|
||||||
t.Error("Expected payload with task")
|
|
||||||
}
|
|
||||||
// Verify test task properties
|
|
||||||
task := data.Payload.Task
|
|
||||||
if task.Protocol != "http" {
|
|
||||||
t.Errorf("Expected protocol 'http', got '%s'", task.Protocol)
|
|
||||||
}
|
|
||||||
if task.Status != base.DownloadStatusDone {
|
|
||||||
t.Errorf("Expected status Done, got %s", task.Status)
|
|
||||||
}
|
|
||||||
if task.Meta == nil || task.Meta.Req == nil {
|
|
||||||
t.Error("Expected meta and request in test task")
|
|
||||||
}
|
|
||||||
if task.Meta.Req.URL != "https://example.com/test-file.zip" {
|
|
||||||
t.Errorf("Expected test URL, got %s", task.Meta.Req.URL)
|
|
||||||
}
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Error("Timeout waiting for webhook")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupWebhookTest(t *testing.T, fn func(downloader *Downloader)) {
|
|
||||||
defaultDownloader.Setup()
|
|
||||||
defaultDownloader.cfg.StorageDir = ".test_storage"
|
|
||||||
defaultDownloader.cfg.DownloadDir = ".test_download"
|
|
||||||
defer func() {
|
|
||||||
defaultDownloader.Clear()
|
|
||||||
os.RemoveAll(defaultDownloader.cfg.StorageDir)
|
|
||||||
os.RemoveAll(defaultDownloader.cfg.DownloadDir)
|
|
||||||
}()
|
|
||||||
fn(defaultDownloader)
|
|
||||||
}
|
|
||||||
@@ -9,28 +9,11 @@ type ReqExtra struct {
|
|||||||
type OptsExtra struct {
|
type OptsExtra struct {
|
||||||
Connections int `json:"connections"`
|
Connections int `json:"connections"`
|
||||||
// AutoTorrent when task download complete, and it is a .torrent file, it will be auto create a new task for the torrent file
|
// AutoTorrent when task download complete, and it is a .torrent file, it will be auto create a new task for the torrent file
|
||||||
// nil means use global config, true/false means explicit setting
|
AutoTorrent bool `json:"autoTorrent"`
|
||||||
AutoTorrent *bool `json:"autoTorrent"`
|
|
||||||
// DeleteTorrentAfterDownload when true, deletes the .torrent file after creating BT task
|
|
||||||
// nil means use global config, true/false means explicit setting
|
|
||||||
DeleteTorrentAfterDownload *bool `json:"deleteTorrentAfterDownload"`
|
|
||||||
// AutoExtract when task download complete, and it is an archive file, it will be auto extracted
|
|
||||||
// nil means use global config, true/false means explicit setting
|
|
||||||
AutoExtract *bool `json:"autoExtract"`
|
|
||||||
// ArchivePassword is the password for extracting password-protected archives
|
|
||||||
ArchivePassword string `json:"archivePassword"`
|
|
||||||
// DeleteAfterExtract when true, deletes the archive file after successful extraction
|
|
||||||
DeleteAfterExtract bool `json:"deleteAfterExtract"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats for download
|
// Stats for download
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Connections []*StatsConnection `json:"connections"`
|
// http stats
|
||||||
}
|
// health indicators of http
|
||||||
|
|
||||||
type StatsConnection struct {
|
|
||||||
Downloaded int64 `json:"downloaded"`
|
|
||||||
Completed bool `json:"completed"`
|
|
||||||
Failed bool `json:"failed"`
|
|
||||||
RetryTimes int `json:"retryTimes"`
|
|
||||||
}
|
}
|
||||||
+10
-46
@@ -1,15 +1,14 @@
|
|||||||
package rest
|
package rest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"runtime"
|
|
||||||
|
|
||||||
"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/rest/model"
|
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Info(w http.ResponseWriter, r *http.Request) {
|
func Info(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -24,9 +23,9 @@ func Info(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Resolve(w http.ResponseWriter, r *http.Request) {
|
func Resolve(w http.ResponseWriter, r *http.Request) {
|
||||||
var req model.ResolveTask
|
var req base.Request
|
||||||
if ReadJson(r, w, &req) {
|
if ReadJson(r, w, &req) {
|
||||||
rr, err := Downloader.Resolve(req.Req, req.Opts)
|
rr, err := Downloader.Resolve(&req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
WriteJson(w, model.NewErrorResult(err.Error()))
|
||||||
return
|
return
|
||||||
@@ -43,9 +42,9 @@ func CreateTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
if req.Rid != "" {
|
if req.Rid != "" {
|
||||||
taskId, err = Downloader.Create(req.Rid)
|
taskId, err = Downloader.Create(req.Rid, req.Opt)
|
||||||
} else if req.Req != nil {
|
} else if req.Req != nil {
|
||||||
taskId, err = Downloader.CreateDirect(req.Req, req.Opts)
|
taskId, err = Downloader.CreateDirect(req.Req, req.Opt)
|
||||||
} else {
|
} else {
|
||||||
WriteJson(w, model.NewErrorResult("param invalid: rid or req", model.CodeInvalidParam))
|
WriteJson(w, model.NewErrorResult("param invalid: rid or req", model.CodeInvalidParam))
|
||||||
return
|
return
|
||||||
@@ -59,13 +58,13 @@ func CreateTask(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func CreateTaskBatch(w http.ResponseWriter, r *http.Request) {
|
func CreateTaskBatch(w http.ResponseWriter, r *http.Request) {
|
||||||
var req base.CreateTaskBatch
|
var req model.CreateTaskBatch
|
||||||
if ReadJson(r, w, &req) {
|
if ReadJson(r, w, &req) {
|
||||||
if len(req.Reqs) == 0 {
|
if len(req.Reqs) == 0 {
|
||||||
WriteJson(w, model.NewErrorResult("param invalid: reqs", model.CodeInvalidParam))
|
WriteJson(w, model.NewErrorResult("param invalid: reqs", model.CodeInvalidParam))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
taskIds, err := Downloader.CreateDirectBatch(&req)
|
taskIds, err := Downloader.CreateDirectBatch(req.Reqs, req.Opt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
WriteJson(w, model.NewErrorResult(err.Error()))
|
||||||
return
|
return
|
||||||
@@ -74,28 +73,6 @@ func CreateTaskBatch(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func PatchTask(w http.ResponseWriter, r *http.Request) {
|
|
||||||
vars := mux.Vars(r)
|
|
||||||
taskId := vars["id"]
|
|
||||||
if taskId == "" {
|
|
||||||
WriteJson(w, model.NewErrorResult("param invalid: id", model.CodeInvalidParam))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req model.ResolveTask
|
|
||||||
if ReadJson(r, w, &req) {
|
|
||||||
if err := Downloader.Patch(taskId, req.Req, req.Opts); err != nil {
|
|
||||||
if err == download.ErrTaskNotFound {
|
|
||||||
WriteJson(w, model.NewErrorResult("task not found", model.CodeTaskNotFound))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
WriteJson(w, model.NewNilResult())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func PauseTask(w http.ResponseWriter, r *http.Request) {
|
func PauseTask(w http.ResponseWriter, r *http.Request) {
|
||||||
filter, errResult := parseIdFilter(r)
|
filter, errResult := parseIdFilter(r)
|
||||||
if errResult != nil {
|
if errResult != nil {
|
||||||
@@ -332,8 +309,6 @@ func DoProxy(w http.ResponseWriter, r *http.Request) {
|
|||||||
r.RequestURI = ""
|
r.RequestURI = ""
|
||||||
r.URL = targetUrl
|
r.URL = targetUrl
|
||||||
r.Host = targetUrl.Host
|
r.Host = targetUrl.Host
|
||||||
r.Header.Del("Authorization")
|
|
||||||
r.Header.Del("X-Target-Uri")
|
|
||||||
resp, err := http.DefaultClient.Do(r)
|
resp, err := http.DefaultClient.Do(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err.Error())
|
writeError(w, err.Error())
|
||||||
@@ -412,14 +387,3 @@ func getServerConfig() *base.DownloaderStoreConfig {
|
|||||||
cfg, _ := Downloader.GetConfig()
|
cfg, _ := Downloader.GetConfig()
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhook(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req model.TestWebhookReq
|
|
||||||
if ReadJson(r, w, &req) {
|
|
||||||
if err := Downloader.TestWebhookUrl(req.URL); err != nil {
|
|
||||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
WriteJson(w, model.NewNilResult())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
package rest
|
|
||||||
|
|
||||||
import (
|
|
||||||
"compress/gzip"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type gzipResponseWriter struct {
|
|
||||||
io.Writer
|
|
||||||
http.ResponseWriter
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g gzipResponseWriter) Write(b []byte) (int, error) {
|
|
||||||
return g.Writer.Write(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func gzipMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Encoding", "gzip")
|
|
||||||
w.Header().Add("Vary", "Accept-Encoding")
|
|
||||||
|
|
||||||
gz := gzip.NewWriter(w)
|
|
||||||
defer gz.Close()
|
|
||||||
|
|
||||||
next.ServeHTTP(gzipResponseWriter{Writer: gz, ResponseWriter: w}, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"github.com/GopeedLab/gopeed/pkg/base"
|
"github.com/GopeedLab/gopeed/pkg/base"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
)
|
)
|
||||||
@@ -24,9 +25,9 @@ type StartConfig struct {
|
|||||||
|
|
||||||
ProductionMode bool
|
ProductionMode bool
|
||||||
|
|
||||||
WebEnable bool
|
WebEnable bool
|
||||||
WebFS fs.FS
|
WebFS fs.FS
|
||||||
WebAuth *WebAuth
|
WebBasicAuth *WebBasicAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cfg *StartConfig) Init() *StartConfig {
|
func (cfg *StartConfig) Init() *StartConfig {
|
||||||
@@ -48,7 +49,13 @@ func (cfg *StartConfig) Init() *StartConfig {
|
|||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebAuth struct {
|
type WebBasicAuth struct {
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authorization returns the value of the Authorization header to be used in HTTP requests.
|
||||||
|
func (cfg *WebBasicAuth) Authorization() string {
|
||||||
|
userId := cfg.Username + ":" + cfg.Password
|
||||||
|
return "Basic " + base64.StdEncoding.EncodeToString([]byte(userId))
|
||||||
|
}
|
||||||
@@ -2,14 +2,13 @@ package model
|
|||||||
|
|
||||||
import "github.com/GopeedLab/gopeed/pkg/base"
|
import "github.com/GopeedLab/gopeed/pkg/base"
|
||||||
|
|
||||||
type ResolveTask struct {
|
|
||||||
Req *base.Request `json:"req"`
|
|
||||||
Opts *base.Options `json:"opts"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CreateTask struct {
|
type CreateTask struct {
|
||||||
Rid string `json:"rid"`
|
Rid string `json:"rid"`
|
||||||
|
Req *base.Request `json:"req"`
|
||||||
|
Opt *base.Options `json:"opt"`
|
||||||
|
}
|
||||||
|
|
||||||
Req *base.Request `json:"req"`
|
type CreateTaskBatch struct {
|
||||||
Opts *base.Options `json:"opts"`
|
Reqs []*base.Request `json:"reqs"`
|
||||||
|
Opt *base.Options `json:"opt"`
|
||||||
}
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// TestWebhookReq is the request body for testing a single webhook URL
|
|
||||||
type TestWebhookReq struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
+21
-217
@@ -2,35 +2,25 @@ package rest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/pkg/download"
|
"github.com/GopeedLab/gopeed/pkg/download"
|
||||||
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||||
"github.com/GopeedLab/gopeed/pkg/util"
|
"github.com/GopeedLab/gopeed/pkg/util"
|
||||||
"github.com/gorilla/handlers"
|
"github.com/gorilla/handlers"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
srv *http.Server
|
srv *http.Server
|
||||||
runningPort int
|
runningPort int
|
||||||
aesKey []byte
|
|
||||||
|
|
||||||
Downloader *download.Downloader
|
Downloader *download.Downloader
|
||||||
)
|
)
|
||||||
@@ -66,9 +56,7 @@ func Stop() {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
if srv != nil {
|
if srv != nil {
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
if err := srv.Shutdown(context.TODO()); err != nil {
|
||||||
defer cancel()
|
|
||||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
||||||
Downloader.Logger.Warn().Err(err).Msg("shutdown server failed")
|
Downloader.Logger.Warn().Err(err).Msg("shutdown server failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,9 +74,8 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
|||||||
startCfg.Init()
|
startCfg.Init()
|
||||||
|
|
||||||
downloadCfg := &download.DownloaderConfig{
|
downloadCfg := &download.DownloaderConfig{
|
||||||
ProductionMode: startCfg.ProductionMode,
|
ProductionMode: startCfg.ProductionMode,
|
||||||
RefreshInterval: startCfg.RefreshInterval,
|
RefreshInterval: startCfg.RefreshInterval,
|
||||||
WhiteDownloadDirs: startCfg.WhiteDownloadDirs,
|
|
||||||
}
|
}
|
||||||
if startCfg.Storage == model.StorageBolt {
|
if startCfg.Storage == model.StorageBolt {
|
||||||
downloadCfg.Storage = download.NewBoltStorage(startCfg.StorageDir)
|
downloadCfg.Storage = download.NewBoltStorage(startCfg.StorageDir)
|
||||||
@@ -106,13 +93,6 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
|||||||
util.SafeRemove(startCfg.Address)
|
util.SafeRemove(startCfg.Address)
|
||||||
}
|
}
|
||||||
|
|
||||||
if startCfg.WebEnable {
|
|
||||||
aesKey = make([]byte, 32)
|
|
||||||
if _, err := rand.Read(aesKey); err != nil {
|
|
||||||
return nil, nil, errors.Wrap(err, "generate aes key failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
listener, err := net.Listen(startCfg.Network, startCfg.Address)
|
listener, err := net.Listen(startCfg.Network, startCfg.Address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -123,7 +103,6 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
|||||||
r.Methods(http.MethodPost).Path("/api/v1/resolve").HandlerFunc(Resolve)
|
r.Methods(http.MethodPost).Path("/api/v1/resolve").HandlerFunc(Resolve)
|
||||||
r.Methods(http.MethodPost).Path("/api/v1/tasks").HandlerFunc(CreateTask)
|
r.Methods(http.MethodPost).Path("/api/v1/tasks").HandlerFunc(CreateTask)
|
||||||
r.Methods(http.MethodPost).Path("/api/v1/tasks/batch").HandlerFunc(CreateTaskBatch)
|
r.Methods(http.MethodPost).Path("/api/v1/tasks/batch").HandlerFunc(CreateTaskBatch)
|
||||||
r.Methods(http.MethodPatch).Path("/api/v1/tasks/{id}").HandlerFunc(PatchTask)
|
|
||||||
r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/pause").HandlerFunc(PauseTask)
|
r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/pause").HandlerFunc(PauseTask)
|
||||||
r.Methods(http.MethodPut).Path("/api/v1/tasks/pause").HandlerFunc(PauseTasks)
|
r.Methods(http.MethodPut).Path("/api/v1/tasks/pause").HandlerFunc(PauseTasks)
|
||||||
r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/continue").HandlerFunc(ContinueTask)
|
r.Methods(http.MethodPut).Path("/api/v1/tasks/{id}/continue").HandlerFunc(ContinueTask)
|
||||||
@@ -143,96 +122,28 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
|||||||
r.Methods(http.MethodDelete).Path("/api/v1/extensions/{identity}").HandlerFunc(DeleteExtension)
|
r.Methods(http.MethodDelete).Path("/api/v1/extensions/{identity}").HandlerFunc(DeleteExtension)
|
||||||
r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateCheckExtension)
|
r.Methods(http.MethodGet).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateCheckExtension)
|
||||||
r.Methods(http.MethodPost).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateExtension)
|
r.Methods(http.MethodPost).Path("/api/v1/extensions/{identity}/update").HandlerFunc(UpdateExtension)
|
||||||
r.Methods(http.MethodPost).Path("/api/v1/webhook/test").HandlerFunc(TestWebhook)
|
|
||||||
r.Path("/api/v1/proxy").HandlerFunc(DoProxy)
|
r.Path("/api/v1/proxy").HandlerFunc(DoProxy)
|
||||||
|
|
||||||
enableApiToken := startCfg.ApiToken != ""
|
|
||||||
enableWebAuth := startCfg.WebEnable && startCfg.WebAuth != nil
|
|
||||||
if startCfg.WebEnable {
|
if startCfg.WebEnable {
|
||||||
if enableWebAuth {
|
|
||||||
r.Methods(http.MethodPost).Path("/api/web/login").HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var loginReq model.WebAuth
|
|
||||||
if ReadJson(r, w, &loginReq) {
|
|
||||||
if loginReq.Username == startCfg.WebAuth.Username && loginReq.Password == startCfg.WebAuth.Password {
|
|
||||||
// Generate a login token, Username:Password:Timestamp
|
|
||||||
timestamp := time.Now().Unix()
|
|
||||||
tokenData := fmt.Sprintf("%s:%s:%d", loginReq.Username, loginReq.Password, timestamp)
|
|
||||||
token, err := aesEncrypt(aesKey, []byte(tokenData))
|
|
||||||
if err != nil {
|
|
||||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
WriteJson(w, model.NewOkResult(token))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
r.PathPrefix("/fs/tasks").Handler(http.FileServer(new(taskFileSystem)))
|
r.PathPrefix("/fs/tasks").Handler(http.FileServer(new(taskFileSystem)))
|
||||||
r.PathPrefix("/fs/extensions").Handler(http.FileServer(new(extensionFileSystem)))
|
r.PathPrefix("/fs/extensions").Handler(http.FileServer(new(extensionFileSystem)))
|
||||||
r.PathPrefix("/").Handler(gzipMiddleware(http.FileServer(newEmbedCacheFileSystem(http.FS(startCfg.WebFS)))))
|
r.PathPrefix("/").Handler(http.FileServer(http.FS(startCfg.WebFS)))
|
||||||
}
|
}
|
||||||
if enableApiToken || enableWebAuth {
|
|
||||||
writeUnauthorized := func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if startCfg.ApiToken != "" || (startCfg.WebEnable && startCfg.WebBasicAuth != nil) {
|
||||||
r.Use(func(h http.Handler) http.Handler {
|
r.Use(func(h http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if enableApiToken {
|
if startCfg.ApiToken != "" && r.Header.Get("X-Api-Token") == startCfg.ApiToken {
|
||||||
apiTokenHeader := r.Header["X-Api-Token"]
|
|
||||||
// If api token header is set, only check api token ignore basic auth
|
|
||||||
if len(apiTokenHeader) > 0 {
|
|
||||||
if apiTokenHeader[0] == startCfg.ApiToken {
|
|
||||||
h.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeUnauthorized(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if enableWebAuth {
|
|
||||||
if !strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api/web/login" {
|
|
||||||
h.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token := r.Header.Get("Authorization")
|
|
||||||
if token == "" {
|
|
||||||
writeUnauthorized(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token = strings.TrimPrefix(token, "Bearer ")
|
|
||||||
tokenData, err := aesDecrypt(aesKey, token)
|
|
||||||
if err != nil {
|
|
||||||
writeUnauthorized(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
parts := strings.SplitN(string(tokenData), ":", 3)
|
|
||||||
username := parts[0]
|
|
||||||
password := parts[1]
|
|
||||||
timestamp, _ := strconv.Atoi(parts[2])
|
|
||||||
|
|
||||||
if username != startCfg.WebAuth.Username || password != startCfg.WebAuth.Password {
|
|
||||||
writeUnauthorized(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the token is expired (7 days)
|
|
||||||
if time.Now().Unix()-int64(timestamp) > 7*24*3600 {
|
|
||||||
writeUnauthorized(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.ServeHTTP(w, r)
|
h.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeUnauthorized(w, r)
|
if startCfg.WebEnable && startCfg.WebBasicAuth != nil {
|
||||||
|
if r.Header.Get("Authorization") == startCfg.WebBasicAuth.Authorization() {
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("WWW-Authenticate", "Basic realm=\"gopeed web\"")
|
||||||
|
}
|
||||||
|
WriteStatusJson(w, http.StatusUnauthorized, model.NewErrorResult("unauthorized", model.CodeUnauthorized))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -252,10 +163,9 @@ func BuildServer(startCfg *model.StartConfig) (*http.Server, net.Listener, error
|
|||||||
})
|
})
|
||||||
|
|
||||||
srv = &http.Server{Handler: handlers.CORS(
|
srv = &http.Server{Handler: handlers.CORS(
|
||||||
handlers.AllowedHeaders([]string{"Content-Type", "Authorization", "X-Api-Token", "X-Target-Uri"}),
|
handlers.AllowedHeaders([]string{"Content-Type", "X-Api-Token", "X-Target-Uri"}),
|
||||||
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}),
|
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}),
|
||||||
handlers.AllowedOrigins([]string{"*"}),
|
handlers.AllowedOrigins([]string{"*"}),
|
||||||
handlers.AllowCredentials(),
|
|
||||||
)(r)}
|
)(r)}
|
||||||
return srv, listener, nil
|
return srv, listener, nil
|
||||||
}
|
}
|
||||||
@@ -312,68 +222,6 @@ func (e *extensionFileSystem) Open(name string) (http.File, error) {
|
|||||||
return os.Open(filepath.Join(extensionPath, path))
|
return os.Open(filepath.Join(extensionPath, path))
|
||||||
}
|
}
|
||||||
|
|
||||||
type embedCacheFileSystem struct {
|
|
||||||
fs http.FileSystem
|
|
||||||
lastModTime time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func newEmbedCacheFileSystem(fs http.FileSystem) *embedCacheFileSystem {
|
|
||||||
efs := &embedCacheFileSystem{
|
|
||||||
fs: fs,
|
|
||||||
lastModTime: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
exe, err := os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
return efs
|
|
||||||
}
|
|
||||||
|
|
||||||
fi, err := os.Stat(exe)
|
|
||||||
if err != nil {
|
|
||||||
return efs
|
|
||||||
}
|
|
||||||
|
|
||||||
efs.lastModTime = fi.ModTime()
|
|
||||||
return efs
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *embedCacheFileSystem) Open(name string) (http.File, error) {
|
|
||||||
file, err := e.fs.Open(name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &embedFile{
|
|
||||||
File: file,
|
|
||||||
lastModTime: e.lastModTime,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type embedFile struct {
|
|
||||||
http.File
|
|
||||||
lastModTime time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type embedFileInfo struct {
|
|
||||||
fs.FileInfo
|
|
||||||
lastModTime time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *embedFileInfo) ModTime() time.Time {
|
|
||||||
return e.lastModTime
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *embedFile) Stat() (fs.FileInfo, error) {
|
|
||||||
fi, err := e.File.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &embedFileInfo{
|
|
||||||
FileInfo: fi,
|
|
||||||
lastModTime: e.lastModTime,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ReadJson(r *http.Request, w http.ResponseWriter, v any) bool {
|
func ReadJson(r *http.Request, w http.ResponseWriter, v any) bool {
|
||||||
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
WriteJson(w, model.NewErrorResult(err.Error()))
|
||||||
@@ -391,47 +239,3 @@ func WriteStatusJson(w http.ResponseWriter, statusCode int, v any) {
|
|||||||
w.WriteHeader(statusCode)
|
w.WriteHeader(statusCode)
|
||||||
json.NewEncoder(w).Encode(v)
|
json.NewEncoder(w).Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
func aesEncrypt(key, data []byte) (string, error) {
|
|
||||||
block, err := aes.NewCipher(key)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
gcm, err := cipher.NewGCM(block)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
nonce := make([]byte, gcm.NonceSize())
|
|
||||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
cipherText := gcm.Seal(nonce, nonce, data, nil)
|
|
||||||
return base64.StdEncoding.EncodeToString(cipherText), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func aesDecrypt(key []byte, encryptedData string) ([]byte, error) {
|
|
||||||
cipherText, err := base64.StdEncoding.DecodeString(encryptedData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
block, err := aes.NewCipher(key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
gcm, err := cipher.NewGCM(block)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cipherText) < gcm.NonceSize() {
|
|
||||||
return nil, errors.New("ciphertext too short")
|
|
||||||
}
|
|
||||||
|
|
||||||
nonce, cipherText := cipherText[:gcm.NonceSize()], cipherText[gcm.NonceSize():]
|
|
||||||
return gcm.Open(nil, nonce, cipherText, nil)
|
|
||||||
}
|
|
||||||
+39
-367
@@ -6,21 +6,18 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/GopeedLab/gopeed/internal/test"
|
"github.com/GopeedLab/gopeed/internal/test"
|
||||||
"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/rest/model"
|
"github.com/GopeedLab/gopeed/pkg/rest/model"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -53,13 +50,13 @@ var (
|
|||||||
"connections": 2,
|
"connections": 2,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
resolveReq = &model.ResolveTask{
|
|
||||||
Req: taskReq,
|
|
||||||
Opts: createOpts,
|
|
||||||
}
|
|
||||||
createReq = &model.CreateTask{
|
createReq = &model.CreateTask{
|
||||||
Req: taskReq,
|
Req: taskReq,
|
||||||
Opts: createOpts,
|
Opt: createOpts,
|
||||||
|
}
|
||||||
|
createResoledReq = &model.CreateTask{
|
||||||
|
Req: taskReq,
|
||||||
|
Opt: createOpts,
|
||||||
}
|
}
|
||||||
installExtensionReq = &model.InstallExtension{
|
installExtensionReq = &model.InstallExtension{
|
||||||
URL: "https://github.com/GopeedLab/gopeed-extension-samples#github-contributor-avatars-sample",
|
URL: "https://github.com/GopeedLab/gopeed-extension-samples#github-contributor-avatars-sample",
|
||||||
@@ -80,7 +77,7 @@ func TestInfo(t *testing.T) {
|
|||||||
|
|
||||||
func TestResolve(t *testing.T) {
|
func TestResolve(t *testing.T) {
|
||||||
doTest(func() {
|
doTest(func() {
|
||||||
resp := httpRequestCheckOk[*download.ResolveResult](http.MethodPost, "/api/v1/resolve", resolveReq)
|
resp := httpRequestCheckOk[*download.ResolveResult](http.MethodPost, "/api/v1/resolve", taskReq)
|
||||||
if !test.AssertResourceEqual(taskRes, resp.Res) {
|
if !test.AssertResourceEqual(taskRes, resp.Res) {
|
||||||
t.Errorf("Resolve() got = %v, want %v", test.ToJson(resp.Res), test.ToJson(taskRes))
|
t.Errorf("Resolve() got = %v, want %v", test.ToJson(resp.Res), test.ToJson(taskRes))
|
||||||
}
|
}
|
||||||
@@ -89,7 +86,7 @@ func TestResolve(t *testing.T) {
|
|||||||
|
|
||||||
func TestCreateTask(t *testing.T) {
|
func TestCreateTask(t *testing.T) {
|
||||||
doTest(func() {
|
doTest(func() {
|
||||||
resp := httpRequestCheckOk[*download.ResolveResult](http.MethodPost, "/api/v1/resolve", resolveReq)
|
resp := httpRequestCheckOk[*download.ResolveResult](http.MethodPost, "/api/v1/resolve", taskReq)
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
@@ -101,6 +98,7 @@ func TestCreateTask(t *testing.T) {
|
|||||||
|
|
||||||
taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", &model.CreateTask{
|
taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", &model.CreateTask{
|
||||||
Rid: resp.ID,
|
Rid: resp.ID,
|
||||||
|
Opt: createOpts,
|
||||||
})
|
})
|
||||||
if taskId == "" {
|
if taskId == "" {
|
||||||
t.Fatal("create task failed")
|
t.Fatal("create task failed")
|
||||||
@@ -141,13 +139,11 @@ func TestCreateDirectTask(t *testing.T) {
|
|||||||
|
|
||||||
func TestCreateDirectTaskBatch(t *testing.T) {
|
func TestCreateDirectTaskBatch(t *testing.T) {
|
||||||
doTest(func() {
|
doTest(func() {
|
||||||
reqs := make([]*base.CreateTaskBatchItem, 0)
|
reqs := make([]*base.Request, 0)
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
reqs = append(reqs, &base.CreateTaskBatchItem{
|
reqs = append(reqs, createReq.Req)
|
||||||
Req: createReq.Req,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
taskIds := httpRequestCheckOk[[]string](http.MethodPost, "/api/v1/tasks/batch", &base.CreateTaskBatch{
|
taskIds := httpRequestCheckOk[[]string](http.MethodPost, "/api/v1/tasks/batch", &model.CreateTaskBatch{
|
||||||
Reqs: reqs,
|
Reqs: reqs,
|
||||||
})
|
})
|
||||||
if len(taskIds) != len(reqs) {
|
if len(taskIds) != len(reqs) {
|
||||||
@@ -156,41 +152,26 @@ func TestCreateDirectTaskBatch(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateDirectTaskBatchWithOpt(t *testing.T) {
|
func TestCreateDirectTaskWithResoled(t *testing.T) {
|
||||||
doTest(func() {
|
doTest(func() {
|
||||||
reqs := make([]*base.CreateTaskBatchItem, 0)
|
var wg sync.WaitGroup
|
||||||
for i := 0; i < 5; i++ {
|
wg.Add(1)
|
||||||
item := &base.CreateTaskBatchItem{
|
Downloader.Listener(func(event *download.Event) {
|
||||||
Req: createReq.Req,
|
if event.Key == download.EventKeyFinally {
|
||||||
|
wg.Done()
|
||||||
}
|
}
|
||||||
if i == 0 {
|
|
||||||
item.Opts = &base.Options{
|
|
||||||
Name: "spe_opt.data",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reqs = append(reqs, item)
|
|
||||||
}
|
|
||||||
taskIds := httpRequestCheckOk[[]string](http.MethodPost, "/api/v1/tasks/batch", &base.CreateTaskBatch{
|
|
||||||
Reqs: reqs,
|
|
||||||
Opts: &base.Options{
|
|
||||||
Name: "default_opt.data",
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
if len(taskIds) != len(reqs) {
|
|
||||||
t.Errorf("CreateDirectTaskBatch() got = %v, want %v", len(taskIds), len(reqs))
|
taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createResoledReq)
|
||||||
|
if taskId == "" {
|
||||||
|
t.Fatal("create task failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, taskId := range taskIds {
|
wg.Wait()
|
||||||
task := httpRequestCheckOk[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil)
|
want := test.FileMd5(test.BuildFile)
|
||||||
if i == 0 {
|
got := test.FileMd5(test.DownloadFile)
|
||||||
if !strings.Contains(task.Name(), "spe_opt") {
|
if want != got {
|
||||||
t.Errorf("CreateDirectTaskBatch() got = %v, want %v", task.Name(), "spe_opt.data")
|
t.Errorf("CreateDirectTaskWithResoled() got = %v, want %v", got, want)
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if !strings.Contains(task.Name(), "default_opt") {
|
|
||||||
t.Errorf("CreateDirectTaskBatch() got = %v, want %v", task.Name(), "default_opt.data")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -216,7 +197,6 @@ func TestPauseAndContinueTask(t *testing.T) {
|
|||||||
if t2.Status != base.DownloadStatusPause {
|
if t2.Status != base.DownloadStatusPause {
|
||||||
t.Errorf("PauseTask() got = %v, want %v", t2.Status, base.DownloadStatusPause)
|
t.Errorf("PauseTask() got = %v, want %v", t2.Status, base.DownloadStatusPause)
|
||||||
}
|
}
|
||||||
time.Sleep(time.Millisecond * 100)
|
|
||||||
httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/"+taskId+"/continue", nil)
|
httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/"+taskId+"/continue", nil)
|
||||||
t3 := httpRequestCheckOk[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil)
|
t3 := httpRequestCheckOk[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil)
|
||||||
if t3.Status != base.DownloadStatusRunning {
|
if t3.Status != base.DownloadStatusRunning {
|
||||||
@@ -232,60 +212,6 @@ func TestPauseAndContinueTask(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPatchTask(t *testing.T) {
|
|
||||||
doTest(func() {
|
|
||||||
// Create a task
|
|
||||||
taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createReq)
|
|
||||||
if taskId == "" {
|
|
||||||
t.Fatal("create task failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pause the task
|
|
||||||
httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/"+taskId+"/pause", nil)
|
|
||||||
time.Sleep(time.Millisecond * 100)
|
|
||||||
|
|
||||||
// Patch the task with new labels
|
|
||||||
patchReq := &model.ResolveTask{
|
|
||||||
Req: &base.Request{
|
|
||||||
Labels: map[string]string{
|
|
||||||
"patched": "true",
|
|
||||||
"version": "2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
httpRequestCheckOk[any](http.MethodPatch, "/api/v1/tasks/"+taskId, patchReq)
|
|
||||||
|
|
||||||
// Verify the patch was applied
|
|
||||||
task := httpRequestCheckOk[*download.Task](http.MethodGet, "/api/v1/tasks/"+taskId, nil)
|
|
||||||
if task.Meta.Req.Labels["patched"] != "true" {
|
|
||||||
t.Errorf("PatchTask() label 'patched' = %v, want %v", task.Meta.Req.Labels["patched"], "true")
|
|
||||||
}
|
|
||||||
if task.Meta.Req.Labels["version"] != "2" {
|
|
||||||
t.Errorf("PatchTask() label 'version' = %v, want %v", task.Meta.Req.Labels["version"], "2")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
httpRequestCheckOk[any](http.MethodDelete, "/api/v1/tasks/"+taskId+"?force=true", nil)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPatchTaskNotFound(t *testing.T) {
|
|
||||||
doTest(func() {
|
|
||||||
// Try to patch a non-existent task
|
|
||||||
patchReq := &model.ResolveTask{
|
|
||||||
Req: &base.Request{
|
|
||||||
Labels: map[string]string{
|
|
||||||
"test": "value",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
code, _ := httpRequest[any](http.MethodPatch, "/api/v1/tasks/non-existent-id", patchReq)
|
|
||||||
if code != int(model.CodeTaskNotFound) {
|
|
||||||
t.Errorf("PatchTaskNotFound() result code = %v, want %v", code, model.CodeTaskNotFound)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPauseAllAndContinueALLTasks(t *testing.T) {
|
func TestPauseAllAndContinueALLTasks(t *testing.T) {
|
||||||
doTest(func() {
|
doTest(func() {
|
||||||
cfg, err := Downloader.GetConfig()
|
cfg, err := Downloader.GetConfig()
|
||||||
@@ -295,7 +221,6 @@ func TestPauseAllAndContinueALLTasks(t *testing.T) {
|
|||||||
|
|
||||||
createAndPause := func() {
|
createAndPause := func() {
|
||||||
taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createReq)
|
taskId := httpRequestCheckOk[string](http.MethodPost, "/api/v1/tasks", createReq)
|
||||||
time.Sleep(time.Millisecond * 5)
|
|
||||||
httpRequestCheckOk[*download.Task](http.MethodPut, "/api/v1/tasks/"+taskId+"/pause", nil)
|
httpRequestCheckOk[*download.Task](http.MethodPut, "/api/v1/tasks/"+taskId+"/pause", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +228,6 @@ func TestPauseAllAndContinueALLTasks(t *testing.T) {
|
|||||||
for i := 0; i < total; i++ {
|
for i := 0; i < total; i++ {
|
||||||
createAndPause()
|
createAndPause()
|
||||||
}
|
}
|
||||||
time.Sleep(time.Millisecond * 50)
|
|
||||||
|
|
||||||
// continue all
|
// continue all
|
||||||
httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/continue", nil)
|
httpRequestCheckOk[any](http.MethodPut, "/api/v1/tasks/continue", nil)
|
||||||
@@ -554,66 +478,6 @@ func TestFsExtensionFail(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebFsEnhance(t *testing.T) {
|
|
||||||
indexHtml := `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>index</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>index</h1>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`
|
|
||||||
webDistPath := "dist"
|
|
||||||
os.MkdirAll("dist", os.ModePerm)
|
|
||||||
if err := os.WriteFile(filepath.Join(webDistPath, "index.html"), []byte(indexHtml), os.ModePerm); err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(webDistPath)
|
|
||||||
|
|
||||||
doTest0(func(cfg *model.StartConfig) {
|
|
||||||
cfg.WebFS = os.DirFS(webDistPath)
|
|
||||||
}, func() {
|
|
||||||
// First request no cache
|
|
||||||
code, header, _ := doHttpRequest1(http.MethodGet, "/index.html", map[string]string{
|
|
||||||
"Accept-Encoding": "gzip",
|
|
||||||
}, nil)
|
|
||||||
if code != http.StatusOK {
|
|
||||||
t.Errorf("TestWebFsEnhance() got = %v, want %v", code, http.StatusOK)
|
|
||||||
}
|
|
||||||
// Check header last-modified
|
|
||||||
if _, ok := header["Last-Modified"]; !ok {
|
|
||||||
t.Errorf("TestWebFsEnhance() missing key = %v", "Last-Modified")
|
|
||||||
}
|
|
||||||
// Check gzip compress
|
|
||||||
if _, ok := header["Content-Encoding"]; !ok || header["Content-Encoding"] != "gzip" {
|
|
||||||
t.Errorf("TestWebFsEnhance() no gzip compress")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request with If-Modified-Since
|
|
||||||
ifModifiedSince := header["Last-Modified"]
|
|
||||||
code, _, _ = doHttpRequest1(http.MethodGet, "/index.html", map[string]string{
|
|
||||||
"If-Modified-Since": ifModifiedSince,
|
|
||||||
}, nil)
|
|
||||||
if code != http.StatusNotModified {
|
|
||||||
t.Errorf("TestWebFsEnhance() got = %v, want %v", code, http.StatusNotModified)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request with un gzip
|
|
||||||
code, header, _ = doHttpRequest1(http.MethodGet, "/index.html?t=123", nil, nil)
|
|
||||||
if code != http.StatusOK {
|
|
||||||
t.Errorf("TestWebFsEnhance() got = %v, want %v", code, http.StatusOK)
|
|
||||||
}
|
|
||||||
// Check no gzip compress
|
|
||||||
if _, ok := header["Content-Encoding"]; ok && header["Content-Encoding"] == "gzip" {
|
|
||||||
t.Errorf("TestWebFsEnhance() has gzip compress")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDoProxy(t *testing.T) {
|
func TestDoProxy(t *testing.T) {
|
||||||
doTest(func() {
|
doTest(func() {
|
||||||
code, respBody := doHttpRequest0(http.MethodGet, "/api/v1/proxy", map[string]string{
|
code, respBody := doHttpRequest0(http.MethodGet, "/api/v1/proxy", map[string]string{
|
||||||
@@ -639,116 +503,6 @@ func TestDoProxy(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTestWebhook(t *testing.T) {
|
|
||||||
doTest(func() {
|
|
||||||
// Set up a mock webhook server
|
|
||||||
webhookReceived := false
|
|
||||||
var receivedData map[string]interface{}
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(1)
|
|
||||||
|
|
||||||
webhookServer := http.Server{
|
|
||||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &receivedData); err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check Content-Type
|
|
||||||
if r.Header.Get("Content-Type") != "application/json" {
|
|
||||||
t.Errorf("TestWebhook() Content-Type got = %v, want %v", r.Header.Get("Content-Type"), "application/json")
|
|
||||||
}
|
|
||||||
|
|
||||||
webhookReceived = true
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
wg.Done()
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start webhook server
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
webhookPort := listener.Addr().(*net.TCPAddr).Port
|
|
||||||
webhookURL := fmt.Sprintf("http://127.0.0.1:%d/webhook", webhookPort)
|
|
||||||
|
|
||||||
go webhookServer.Serve(listener)
|
|
||||||
defer webhookServer.Close()
|
|
||||||
|
|
||||||
// Test with valid webhook URL
|
|
||||||
httpRequestCheckOk[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
|
|
||||||
URL: webhookURL,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait for webhook to be received
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
if !webhookReceived {
|
|
||||||
t.Error("TestWebhook() webhook was not received")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify webhook data structure
|
|
||||||
if receivedData["event"] == nil {
|
|
||||||
t.Error("TestWebhook() missing 'event' field")
|
|
||||||
}
|
|
||||||
if receivedData["time"] == nil {
|
|
||||||
t.Error("TestWebhook() missing 'time' field")
|
|
||||||
}
|
|
||||||
if receivedData["payload"] == nil {
|
|
||||||
t.Error("TestWebhook() missing 'payload' field")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test with invalid webhook URL
|
|
||||||
code, _ := httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
|
|
||||||
URL: "http://invalid-webhook-url-that-does-not-exist.local:99999/webhook",
|
|
||||||
})
|
|
||||||
checkCode(code, model.CodeError)
|
|
||||||
|
|
||||||
// Test with empty URL
|
|
||||||
code, _ = httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
|
|
||||||
URL: "",
|
|
||||||
})
|
|
||||||
checkCode(code, model.CodeError)
|
|
||||||
|
|
||||||
// Test with webhook server returning non-200 status
|
|
||||||
wg.Add(1)
|
|
||||||
badWebhookServer := http.Server{
|
|
||||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
wg.Done()
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
badListener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
badWebhookPort := badListener.Addr().(*net.TCPAddr).Port
|
|
||||||
badWebhookURL := fmt.Sprintf("http://127.0.0.1:%d/webhook", badWebhookPort)
|
|
||||||
|
|
||||||
go badWebhookServer.Serve(badListener)
|
|
||||||
defer badWebhookServer.Close()
|
|
||||||
|
|
||||||
code, _ = httpRequest[any](http.MethodPost, "/api/v1/webhook/test", &model.TestWebhookReq{
|
|
||||||
URL: badWebhookURL,
|
|
||||||
})
|
|
||||||
checkCode(code, model.CodeError)
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApiToken(t *testing.T) {
|
func TestApiToken(t *testing.T) {
|
||||||
var cfg = &model.StartConfig{}
|
var cfg = &model.StartConfig{}
|
||||||
cfg.Init()
|
cfg.Init()
|
||||||
@@ -780,7 +534,7 @@ func TestAuthorization(t *testing.T) {
|
|||||||
cfg.Init()
|
cfg.Init()
|
||||||
cfg.ApiToken = "123456"
|
cfg.ApiToken = "123456"
|
||||||
cfg.WebEnable = true
|
cfg.WebEnable = true
|
||||||
cfg.WebAuth = &model.WebAuth{
|
cfg.WebBasicAuth = &model.WebBasicAuth{
|
||||||
Username: "admin",
|
Username: "admin",
|
||||||
Password: "123456",
|
Password: "123456",
|
||||||
}
|
}
|
||||||
@@ -792,61 +546,14 @@ func TestAuthorization(t *testing.T) {
|
|||||||
Stop()
|
Stop()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
status, _ := doHttpRequest0(http.MethodPost, "/api/web/login", nil, &model.WebAuth{
|
status, _ := doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil)
|
||||||
Username: "xxx",
|
|
||||||
Password: "xxx",
|
|
||||||
})
|
|
||||||
if status != http.StatusUnauthorized {
|
|
||||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
token := httpRequestCheckOk[string](http.MethodPost, "/api/web/login", cfg.WebAuth)
|
|
||||||
authToken := fmt.Sprintf("Bearer %s", token)
|
|
||||||
authHeaders := map[string]string{
|
|
||||||
"Authorization": authToken,
|
|
||||||
}
|
|
||||||
|
|
||||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", nil, nil)
|
|
||||||
if status != http.StatusUnauthorized {
|
if status != http.StatusUnauthorized {
|
||||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
||||||
"Authorization": "xxx",
|
"Authorization": cfg.WebBasicAuth.Authorization(),
|
||||||
}, nil)
|
}, nil)
|
||||||
if status != http.StatusUnauthorized {
|
|
||||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
|
||||||
"Authorization": "xxx",
|
|
||||||
}, nil)
|
|
||||||
if status != http.StatusUnauthorized {
|
|
||||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
buildToken := func(username, password string, ts int64) string {
|
|
||||||
token, _ := aesEncrypt(aesKey, []byte(fmt.Sprintf("%s:%s:%d", username, password, ts)))
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
fakeToken := buildToken("fake", "fake", time.Now().Unix())
|
|
||||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
|
||||||
"Authorization": fmt.Sprintf("Bearer %s", fakeToken),
|
|
||||||
}, nil)
|
|
||||||
if status != http.StatusUnauthorized {
|
|
||||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
expireToken := buildToken(cfg.WebAuth.Username, cfg.WebAuth.Password, time.Now().Add(-time.Hour*8*24).Unix())
|
|
||||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
|
||||||
"Authorization": fmt.Sprintf("Bearer %s", expireToken),
|
|
||||||
}, nil)
|
|
||||||
if status != http.StatusUnauthorized {
|
|
||||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", authHeaders, nil)
|
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK)
|
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK)
|
||||||
}
|
}
|
||||||
@@ -857,38 +564,15 @@ func TestAuthorization(t *testing.T) {
|
|||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK)
|
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
|
||||||
"Authorization": authToken,
|
|
||||||
"X-Api-Token": cfg.ApiToken,
|
|
||||||
}, nil)
|
|
||||||
if status != http.StatusOK {
|
|
||||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
|
|
||||||
"Authorization": authToken,
|
|
||||||
"X-Api-Token": "",
|
|
||||||
}, nil)
|
|
||||||
if status != http.StatusUnauthorized {
|
|
||||||
t.Errorf("TestAuthorization() got = %v, want %v", status, http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func doTest(handler func()) {
|
func doTest(handler func()) {
|
||||||
doTest0(nil, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
func doTest0(onStart func(cfg *model.StartConfig), handler func()) {
|
|
||||||
testFunc := func(storage model.Storage) {
|
testFunc := func(storage model.Storage) {
|
||||||
var cfg = &model.StartConfig{}
|
var cfg = &model.StartConfig{}
|
||||||
cfg.Init()
|
cfg.Init()
|
||||||
cfg.Storage = storage
|
cfg.Storage = storage
|
||||||
cfg.StorageDir = ".test_storage"
|
cfg.StorageDir = ".test_storage"
|
||||||
cfg.WebEnable = true
|
cfg.WebEnable = true
|
||||||
if onStart != nil {
|
|
||||||
onStart(cfg)
|
|
||||||
}
|
|
||||||
fileListener := doStart(cfg)
|
fileListener := doStart(cfg)
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := fileListener.Close(); err != nil {
|
if err := fileListener.Close(); err != nil {
|
||||||
@@ -898,7 +582,6 @@ func doTest0(onStart func(cfg *model.StartConfig), handler func()) {
|
|||||||
Downloader.Clear()
|
Downloader.Clear()
|
||||||
}()
|
}()
|
||||||
defer func() {
|
defer func() {
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
Downloader.Pause(nil)
|
Downloader.Pause(nil)
|
||||||
Downloader.Delete(nil, true)
|
Downloader.Delete(nil, true)
|
||||||
os.RemoveAll(cfg.StorageDir)
|
os.RemoveAll(cfg.StorageDir)
|
||||||
@@ -920,11 +603,6 @@ func doStart(cfg *model.StartConfig) net.Listener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func doHttpRequest0(method string, path string, headers map[string]string, body any) (int, []byte) {
|
func doHttpRequest0(method string, path string, headers map[string]string, body any) (int, []byte) {
|
||||||
r1, _, r3 := doHttpRequest1(method, path, headers, body)
|
|
||||||
return r1, r3
|
|
||||||
}
|
|
||||||
|
|
||||||
func doHttpRequest1(method string, path string, headers map[string]string, body any) (int, map[string]string, []byte) {
|
|
||||||
var reader io.Reader
|
var reader io.Reader
|
||||||
if body != nil {
|
if body != nil {
|
||||||
buf, _ := json.Marshal(body)
|
buf, _ := json.Marshal(body)
|
||||||
@@ -945,18 +623,12 @@ func doHttpRequest1(method string, path string, headers map[string]string, body
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
|
|
||||||
respHeader := make(map[string]string)
|
|
||||||
for k, vv := range response.Header {
|
|
||||||
respHeader[k] = vv[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
respBody, err := io.ReadAll(response.Body)
|
respBody, err := io.ReadAll(response.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.StatusCode, respHeader, respBody
|
return response.StatusCode, respBody
|
||||||
}
|
}
|
||||||
|
|
||||||
func doHttpRequest[T any](method string, path string, headers map[string]string, body any) (int, *model.Result[T]) {
|
func doHttpRequest[T any](method string, path string, headers map[string]string, body any) (int, *model.Result[T]) {
|
||||||
|
|||||||
+1
-14
@@ -5,27 +5,14 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
)
|
)
|
||||||
|
|
||||||
const unknownSize = "unknown"
|
|
||||||
|
|
||||||
var unitArr = []string{"B", "KB", "MB", "GB", "TB", "PB", "EB"}
|
var unitArr = []string{"B", "KB", "MB", "GB", "TB", "PB", "EB"}
|
||||||
|
|
||||||
func ByteFmt(size int64) string {
|
func ByteFmt(size int64) string {
|
||||||
if size == 0 {
|
if size == 0 {
|
||||||
return unknownSize
|
return "unknown"
|
||||||
}
|
|
||||||
// Handle negative values
|
|
||||||
if size < 0 {
|
|
||||||
return unknownSize
|
|
||||||
}
|
}
|
||||||
fs := float64(size)
|
fs := float64(size)
|
||||||
p := int(math.Log(fs) / math.Log(1024))
|
p := int(math.Log(fs) / math.Log(1024))
|
||||||
// Ensure index is within bounds
|
|
||||||
if p < 0 {
|
|
||||||
p = 0
|
|
||||||
}
|
|
||||||
if p >= len(unitArr) {
|
|
||||||
p = len(unitArr) - 1
|
|
||||||
}
|
|
||||||
val := fs / math.Pow(1024, float64(p))
|
val := fs / math.Pow(1024, float64(p))
|
||||||
_, frac := math.Modf(val)
|
_, frac := math.Modf(val)
|
||||||
if frac > 0 {
|
if frac > 0 {
|
||||||
|
|||||||
@@ -14,17 +14,7 @@ func TestByteFmt(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "unknown",
|
name: "unknown",
|
||||||
args: args{size: int64(0)},
|
args: args{size: int64(0)},
|
||||||
want: unknownSize,
|
want: "unknown",
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "negative value",
|
|
||||||
args: args{size: int64(-1)},
|
|
||||||
want: unknownSize,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "negative min int64",
|
|
||||||
args: args{size: int64(-9223372036854775808)},
|
|
||||||
want: unknownSize,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "100B",
|
name: "100B",
|
||||||
@@ -61,11 +51,6 @@ func TestByteFmt(t *testing.T) {
|
|||||||
args: args{size: int64(1024 * 1024 * 2)},
|
args: args{size: int64(1024 * 1024 * 2)},
|
||||||
want: "2MB",
|
want: "2MB",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "large value",
|
|
||||||
args: args{size: int64(9223372036854775807)}, // max int64
|
|
||||||
want: "8EB",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -26,13 +26,3 @@ func DeepClone[T any](v *T) *T {
|
|||||||
json.Unmarshal(b, &t)
|
json.Unmarshal(b, &t)
|
||||||
return &t
|
return &t
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ptr returns a pointer to the given value.
|
|
||||||
func Ptr[T any](v T) *T {
|
|
||||||
return &v
|
|
||||||
}
|
|
||||||
|
|
||||||
// BoolPtr returns a pointer to a bool value.
|
|
||||||
func BoolPtr(v bool) *bool {
|
|
||||||
return &v
|
|
||||||
}
|
|
||||||
+14
-153
@@ -8,8 +8,6 @@ import (
|
|||||||
syspath "path"
|
syspath "path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
"unicode/utf8"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Dir(path string) string {
|
func Dir(path string) string {
|
||||||
@@ -52,17 +50,12 @@ func CheckDuplicateAndRename(path string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := syspath.Ext(name)
|
index := strings.LastIndex(name, ".")
|
||||||
var nameTpl string
|
var nameTpl string
|
||||||
// Special case: if the extension is the entire filename (like .gitignore),
|
if index == -1 {
|
||||||
// or if index of last dot is 0 (starts with dot), treat it as no extension
|
|
||||||
if ext == "" || ext == name || (len(ext) > 0 && strings.LastIndex(name, ".") == 0) {
|
|
||||||
// No extension or hidden file without extension
|
|
||||||
nameTpl = name + " (%d)"
|
nameTpl = name + " (%d)"
|
||||||
} else {
|
} else {
|
||||||
// Has extension
|
nameTpl = name[:index] + " (%d)" + name[index:]
|
||||||
nameWithoutExt := name[:len(name)-len(ext)]
|
|
||||||
nameTpl = nameWithoutExt + " (%d)" + ext
|
|
||||||
}
|
}
|
||||||
for i := 1; ; i++ {
|
for i := 1; ; i++ {
|
||||||
newName := fmt.Sprintf(nameTpl, i)
|
newName := fmt.Sprintf(nameTpl, i)
|
||||||
@@ -142,6 +135,17 @@ func CopyDir(source string, target string, excludeDir ...string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RmAndMkDirAll remove and create directory, if the directory already exists, it will be overwritten.
|
||||||
|
func RmAndMkDirAll(path string) error {
|
||||||
|
if err := os.RemoveAll(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(path, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// copy file, if the target file already exists, it will be overwritten.
|
// copy file, if the target file already exists, it will be overwritten.
|
||||||
func copyForce(source string, target string) error {
|
func copyForce(source string, target string) error {
|
||||||
sourceFile, err := os.Open(source)
|
sourceFile, err := os.Open(source)
|
||||||
@@ -180,65 +184,7 @@ func IsExistsFile(path string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
|
||||||
// MaxFilenameLength is the maximum length in bytes for a filename
|
|
||||||
MaxFilenameLength = 100
|
|
||||||
// maxExtensionLength is the maximum length in bytes for a file extension
|
|
||||||
// to be treated as a valid extension. Extensions longer than this are
|
|
||||||
// treated as part of the filename to avoid edge cases.
|
|
||||||
maxExtensionLength = 20
|
|
||||||
)
|
|
||||||
|
|
||||||
// SafeFilename sanitizes a filename by replacing invalid characters and truncating to a safe length.
|
|
||||||
// It performs two operations:
|
|
||||||
// 1. Replaces invalid path characters (platform-specific) with underscores
|
|
||||||
// 2. Truncates filename to MaxFilenameLength bytes while preserving the file extension
|
|
||||||
// The function handles UTF-8 multi-byte characters correctly by truncating at valid boundaries.
|
|
||||||
func SafeFilename(filename string) string {
|
|
||||||
if filename == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Replace invalid characters
|
|
||||||
for _, char := range invalidPathChars {
|
|
||||||
filename = strings.ReplaceAll(filename, char, "_")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Truncate if needed
|
|
||||||
if len(filename) <= MaxFilenameLength {
|
|
||||||
return filename
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the extension (last dot in filename)
|
|
||||||
ext := ""
|
|
||||||
lastDot := strings.LastIndex(filename, ".")
|
|
||||||
|
|
||||||
// Only treat as extension if:
|
|
||||||
// 1. There is a dot
|
|
||||||
// 2. The dot is not at the start (not a hidden file like .gitignore)
|
|
||||||
// 3. The extension is reasonable length (< maxExtensionLength bytes) to avoid edge cases
|
|
||||||
if lastDot > 0 && lastDot < len(filename)-1 && len(filename)-lastDot < maxExtensionLength {
|
|
||||||
ext = filename[lastDot:]
|
|
||||||
filename = filename[:lastDot]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate how much space we have for the base name
|
|
||||||
availableLength := MaxFilenameLength - len(ext)
|
|
||||||
|
|
||||||
// Ensure we have at least some space for the base name
|
|
||||||
if availableLength < 1 {
|
|
||||||
// Extension itself is too long or no room, just truncate everything at byte boundary
|
|
||||||
return truncateAtValidUTF8Boundary(filename+ext, MaxFilenameLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate the base name at a valid UTF-8 boundary
|
|
||||||
truncatedBase := truncateAtValidUTF8Boundary(filename, availableLength)
|
|
||||||
|
|
||||||
return truncatedBase + ext
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReplaceInvalidFilename replace invalid path characters
|
// ReplaceInvalidFilename replace invalid path characters
|
||||||
// Deprecated: Use SafeFilename instead which also handles length truncation
|
|
||||||
func ReplaceInvalidFilename(path string) string {
|
func ReplaceInvalidFilename(path string) string {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -248,88 +194,3 @@ func ReplaceInvalidFilename(path string) string {
|
|||||||
}
|
}
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
// TruncateFilename truncates a filename to a maximum byte length while preserving the extension.
|
|
||||||
// Deprecated: Use SafeFilename instead which also handles invalid character replacement
|
|
||||||
func TruncateFilename(filename string, maxLength int) string {
|
|
||||||
// If already short enough, return as-is
|
|
||||||
if len(filename) <= maxLength {
|
|
||||||
return filename
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the extension (last dot in filename)
|
|
||||||
ext := ""
|
|
||||||
lastDot := strings.LastIndex(filename, ".")
|
|
||||||
|
|
||||||
// Only treat as extension if:
|
|
||||||
// 1. There is a dot
|
|
||||||
// 2. The dot is not at the start (not a hidden file like .gitignore)
|
|
||||||
// 3. The extension is reasonable length (< maxExtensionLength bytes) to avoid edge cases
|
|
||||||
if lastDot > 0 && lastDot < len(filename)-1 && len(filename)-lastDot < maxExtensionLength {
|
|
||||||
ext = filename[lastDot:]
|
|
||||||
filename = filename[:lastDot]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate how much space we have for the base name
|
|
||||||
availableLength := maxLength - len(ext)
|
|
||||||
|
|
||||||
// Ensure we have at least some space for the base name
|
|
||||||
if availableLength < 1 {
|
|
||||||
// Extension itself is too long or no room, just truncate everything at byte boundary
|
|
||||||
return truncateAtValidUTF8Boundary(filename+ext, maxLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate the base name at a valid UTF-8 boundary
|
|
||||||
truncatedBase := truncateAtValidUTF8Boundary(filename, availableLength)
|
|
||||||
|
|
||||||
return truncatedBase + ext
|
|
||||||
}
|
|
||||||
|
|
||||||
// truncateAtValidUTF8Boundary truncates a string to at most maxBytes,
|
|
||||||
// ensuring we don't cut in the middle of a UTF-8 character
|
|
||||||
func truncateAtValidUTF8Boundary(s string, maxBytes int) string {
|
|
||||||
if len(s) <= maxBytes {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate at byte position
|
|
||||||
truncated := s[:maxBytes]
|
|
||||||
|
|
||||||
// Find the last valid UTF-8 character boundary
|
|
||||||
// Walk backwards to find where the last complete character ends
|
|
||||||
for len(truncated) > 0 {
|
|
||||||
// Check if this is a valid UTF-8 string
|
|
||||||
if utf8.ValidString(truncated) {
|
|
||||||
return truncated
|
|
||||||
}
|
|
||||||
// Remove one byte and try again
|
|
||||||
truncated = truncated[:len(truncated)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
return truncated
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReplacePathPlaceholders replaces date placeholders in a path with actual values
|
|
||||||
// Supported placeholders:
|
|
||||||
// - %year% - Current year (e.g., 2025)
|
|
||||||
// - %month% - Current month (01-12)
|
|
||||||
// - %day% - Current day (01-31)
|
|
||||||
// - %date% - Full date format (2025-01-01)
|
|
||||||
func ReplacePathPlaceholders(path string) string {
|
|
||||||
if path == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
year := fmt.Sprintf("%d", now.Year())
|
|
||||||
month := fmt.Sprintf("%02d", now.Month())
|
|
||||||
day := fmt.Sprintf("%02d", now.Day())
|
|
||||||
date := fmt.Sprintf("%s-%s-%s", year, month, day)
|
|
||||||
|
|
||||||
path = strings.ReplaceAll(path, "%year%", year)
|
|
||||||
path = strings.ReplaceAll(path, "%month%", month)
|
|
||||||
path = strings.ReplaceAll(path, "%day%", day)
|
|
||||||
path = strings.ReplaceAll(path, "%date%", date)
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDir(t *testing.T) {
|
func TestDir(t *testing.T) {
|
||||||
@@ -109,28 +106,13 @@ func TestSafeRemove(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckDuplicateAndRename(t *testing.T) {
|
func TestCheckDuplicateAndRename(t *testing.T) {
|
||||||
// Test with extension
|
|
||||||
doCheckDuplicateAndRename(t, []string{}, "a.txt", "a.txt")
|
doCheckDuplicateAndRename(t, []string{}, "a.txt", "a.txt")
|
||||||
doCheckDuplicateAndRename(t, []string{"a.txt"}, "a.txt", "a (1).txt")
|
doCheckDuplicateAndRename(t, []string{"a.txt"}, "a.txt", "a (1).txt")
|
||||||
doCheckDuplicateAndRename(t, []string{"a.txt", "a (1).txt"}, "a.txt", "a (2).txt")
|
doCheckDuplicateAndRename(t, []string{"a.txt", "a (1).txt"}, "a.txt", "a (2).txt")
|
||||||
|
|
||||||
// Test without extension
|
|
||||||
doCheckDuplicateAndRename(t, []string{}, "a", "a")
|
doCheckDuplicateAndRename(t, []string{}, "a", "a")
|
||||||
doCheckDuplicateAndRename(t, []string{"a"}, "a", "a (1)")
|
doCheckDuplicateAndRename(t, []string{"a"}, "a", "a (1)")
|
||||||
doCheckDuplicateAndRename(t, []string{"a", "a (1)"}, "a", "a (2)")
|
doCheckDuplicateAndRename(t, []string{"a", "a (1)"}, "a", "a (2)")
|
||||||
|
|
||||||
// Test hidden files (starting with dot)
|
|
||||||
doCheckDuplicateAndRename(t, []string{}, ".gitignore", ".gitignore")
|
|
||||||
doCheckDuplicateAndRename(t, []string{".gitignore"}, ".gitignore", ".gitignore (1)")
|
|
||||||
doCheckDuplicateAndRename(t, []string{".gitignore", ".gitignore (1)"}, ".gitignore", ".gitignore (2)")
|
|
||||||
|
|
||||||
// Test hidden files with extension
|
|
||||||
doCheckDuplicateAndRename(t, []string{}, ".config.json", ".config.json")
|
|
||||||
doCheckDuplicateAndRename(t, []string{".config.json"}, ".config.json", ".config (1).json")
|
|
||||||
|
|
||||||
// Test multiple dots
|
|
||||||
doCheckDuplicateAndRename(t, []string{}, "test.tar.gz", "test.tar.gz")
|
|
||||||
doCheckDuplicateAndRename(t, []string{"test.tar.gz"}, "test.tar.gz", "test.tar (1).gz")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func doCheckDuplicateAndRename(t *testing.T, exitsPaths []string, path string, except string) {
|
func doCheckDuplicateAndRename(t *testing.T, exitsPaths []string, path string, except string) {
|
||||||
@@ -243,254 +225,3 @@ func TestReplaceInvalidFilename(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSafeFilename tests the combined filename sanitization functionality
|
|
||||||
func TestSafeFilename(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
filename string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "short filename - no changes needed",
|
|
||||||
filename: "test.txt",
|
|
||||||
want: "test.txt",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty filename",
|
|
||||||
filename: "",
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid chars only",
|
|
||||||
filename: "te/st:file.txt",
|
|
||||||
want: "te_st_file.txt",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "long filename only",
|
|
||||||
filename: "this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length_and_should_be_truncated_properly.txt",
|
|
||||||
want: "this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length_and_should_be_truncated_pro.txt",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "both invalid chars and too long",
|
|
||||||
filename: "path/to/very:long*filename?that<exceeds>filesystem|limits_and_has_invalid_characters_everywhere.pdf",
|
|
||||||
want: "path_to_very_long*filename?that<exceeds>filesystem|limits_and_has_invalid_characters_everywhere.pdf",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unicode with invalid chars and truncation",
|
|
||||||
filename: "测试/文件名:非常长的中文文件名_需要被截断_这是一个测试用的超长文件名.pdf",
|
|
||||||
want: "测试_文件名_非常长的中文文件名_需要被截断_这是一个测试用的超长文.pdf",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "hidden file with truncation",
|
|
||||||
filename: ".gitignore_with_very_long_name_that_needs_truncation_and_more_characters_to_exceed_the_maximum_length",
|
|
||||||
want: ".gitignore_with_very_long_name_that_needs_truncation_and_more_characters_to_exceed_the_maximum_lengt",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple dots and invalid chars",
|
|
||||||
filename: "archive/tar.gz.backup:old.txt",
|
|
||||||
want: "archive_tar.gz.backup_old.txt",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "extension longer than reasonable",
|
|
||||||
filename: "test.verylongextensionthatshouldnotbetreatedasextension_with_more_characters_to_exceed_maximum_length",
|
|
||||||
want: "test.verylongextensionthatshouldnotbetreatedasextension_with_more_characters_to_exceed_maximum_lengt",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "filename with spaces and invalid chars",
|
|
||||||
filename: "my document/with:spaces and a very long name that needs to be truncated because it exceeds the maximum length.docx",
|
|
||||||
want: "my document_with_spaces and a very long name that needs to be truncated because it exceeds the .docx",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := SafeFilename(tt.filename)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("SafeFilename() = %q (len=%d), want %q (len=%d)",
|
|
||||||
got, len(got), tt.want, len(tt.want))
|
|
||||||
}
|
|
||||||
// Verify result doesn't exceed MaxFilenameLength
|
|
||||||
if len(got) > MaxFilenameLength {
|
|
||||||
t.Errorf("SafeFilename() result length %d exceeds MaxFilenameLength %d",
|
|
||||||
len(got), MaxFilenameLength)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReplacePathPlaceholders(t *testing.T) {
|
|
||||||
now := time.Now()
|
|
||||||
year := fmt.Sprintf("%d", now.Year())
|
|
||||||
month := fmt.Sprintf("%02d", now.Month())
|
|
||||||
day := fmt.Sprintf("%02d", now.Day())
|
|
||||||
date := fmt.Sprintf("%s-%s-%s", year, month, day)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
path string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty path",
|
|
||||||
path: "",
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no placeholders",
|
|
||||||
path: "/home/user/Downloads",
|
|
||||||
want: "/home/user/Downloads",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "year placeholder",
|
|
||||||
path: "/Downloads/%year%",
|
|
||||||
want: "/Downloads/" + year,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "month placeholder",
|
|
||||||
path: "/Downloads/%month%",
|
|
||||||
want: "/Downloads/" + month,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "day placeholder",
|
|
||||||
path: "/Downloads/%day%",
|
|
||||||
want: "/Downloads/" + day,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "date placeholder",
|
|
||||||
path: "/Downloads/%date%",
|
|
||||||
want: "/Downloads/" + date,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple placeholders",
|
|
||||||
path: "/Downloads/%year%-%month%",
|
|
||||||
want: "/Downloads/" + year + "-" + month,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed path with placeholders",
|
|
||||||
path: "/home/user/Downloads/%year%/%month%/%day%",
|
|
||||||
want: "/home/user/Downloads/" + year + "/" + month + "/" + day,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "windows style path",
|
|
||||||
path: "D:\\Downloads\\%year%-%month%",
|
|
||||||
want: "D:\\Downloads\\" + year + "-" + month,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := ReplacePathPlaceholders(tt.path)
|
|
||||||
if !strings.Contains(got, year) && tt.path != "" && strings.Contains(tt.path, "%year%") {
|
|
||||||
t.Errorf("ReplacePathPlaceholders() = %v, want containing year %v", got, year)
|
|
||||||
}
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("ReplacePathPlaceholders() = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTruncateFilename tests the filename truncation functionality
|
|
||||||
func TestTruncateFilename(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
filename string
|
|
||||||
maxLength int
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "short filename - no truncation",
|
|
||||||
filename: "test.txt",
|
|
||||||
maxLength: 100,
|
|
||||||
want: "test.txt",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "filename at exact limit",
|
|
||||||
filename: "abcdefghij.txt", // 14 chars
|
|
||||||
maxLength: 14,
|
|
||||||
want: "abcdefghij.txt",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "long filename with extension - truncate base",
|
|
||||||
filename: "this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length_and_should_be_truncated_properly.txt",
|
|
||||||
maxLength: 100,
|
|
||||||
want: "this_is_a_very_long_filename_that_exceeds_the_maximum_allowed_length_and_should_be_truncated_pro.txt",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "long filename without extension",
|
|
||||||
filename: "this_is_a_very_long_filename_without_extension_that_exceeds_maximum_length_and_needs_truncation",
|
|
||||||
maxLength: 100,
|
|
||||||
want: "this_is_a_very_long_filename_without_extension_that_exceeds_maximum_length_and_needs_truncation",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "filename with multiple dots",
|
|
||||||
filename: "archive.tar.gz.backup.old.file.with.many.dots.txt",
|
|
||||||
maxLength: 30,
|
|
||||||
want: "archive.tar.gz.backup.old..txt", // Preserves last extension
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "hidden file (starts with dot)",
|
|
||||||
filename: ".gitignore_with_very_long_name_that_needs_truncation",
|
|
||||||
maxLength: 30,
|
|
||||||
want: ".gitignore_with_very_long_name",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "only extension (no base name)",
|
|
||||||
filename: ".txt",
|
|
||||||
maxLength: 100,
|
|
||||||
want: ".txt",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unicode characters in filename",
|
|
||||||
filename: "测试文件名_非常长的中文文件名_需要被截断_这是一个测试用的超长文件名.pdf",
|
|
||||||
maxLength: 50,
|
|
||||||
want: "测试文件名_非常长的中文文件名_.pdf", // Truncated at byte boundary, preserving UTF-8
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "extension longer than reasonable (>20 chars)",
|
|
||||||
filename: "test.verylongextensionthatshouldnotbetreatedasextension",
|
|
||||||
maxLength: 30,
|
|
||||||
want: "test.verylongextensionthatshou",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "very short max length with extension",
|
|
||||||
filename: "document.pdf",
|
|
||||||
maxLength: 10,
|
|
||||||
want: "docume.pdf",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "maxLength smaller than extension",
|
|
||||||
filename: "test.pdf",
|
|
||||||
maxLength: 3,
|
|
||||||
want: "tes",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty filename",
|
|
||||||
filename: "",
|
|
||||||
maxLength: 100,
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "filename with spaces",
|
|
||||||
filename: "my document with spaces and a very long name that needs to be truncated.docx",
|
|
||||||
maxLength: 50,
|
|
||||||
want: "my document with spaces and a very long name .docx",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := TruncateFilename(tt.filename, tt.maxLength)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("TruncateFilename() = %q (len=%d), want %q (len=%d)",
|
|
||||||
got, len(got), tt.want, len(tt.want))
|
|
||||||
}
|
|
||||||
// Verify result doesn't exceed maxLength
|
|
||||||
if len(got) > tt.maxLength {
|
|
||||||
t.Errorf("TruncateFilename() result length %d exceeds maxLength %d",
|
|
||||||
len(got), tt.maxLength)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -56,26 +56,3 @@ func ProxyUrlToHandler(proxyUrl *url.URL) func(*http.Request) (*url.URL, error)
|
|||||||
}
|
}
|
||||||
return http.ProxyURL(proxyUrl)
|
return http.ProxyURL(proxyUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TryUrlQueryUnescape tries to unescape a URL-encoded string.
|
|
||||||
//
|
|
||||||
// If unescaping fails, it returns the original string.
|
|
||||||
func TryUrlQueryUnescape(s string) string {
|
|
||||||
if decoded, err := url.QueryUnescape(s); err == nil {
|
|
||||||
return decoded
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// TryUrlPathUnescape tries to unescape a URL path-encoded string.
|
|
||||||
// Unlike QueryUnescape, PathUnescape does not treat '+' as a space.
|
|
||||||
// This is the correct function to use for decoding URL paths and filenames
|
|
||||||
// where %2B should decode to '+', not to a space.
|
|
||||||
//
|
|
||||||
// If unescaping fails, it returns the original string.
|
|
||||||
func TryUrlPathUnescape(s string) string {
|
|
||||||
if decoded, err := url.PathUnescape(s); err == nil {
|
|
||||||
return decoded
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
@@ -128,52 +128,3 @@ func TestParseDataUri(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTryURLDecode(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{"normal.txt", "normal.txt"},
|
|
||||||
{"%E7%8F%80%E5%B0%94%E8%AF%BA.zip", "珀尔诺.zip"},
|
|
||||||
{"hello%20world.txt", "hello world.txt"},
|
|
||||||
{"bad%2-text", "bad%2-text"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.input, func(t *testing.T) {
|
|
||||||
got := TryUrlQueryUnescape(tt.input)
|
|
||||||
if got != tt.expected {
|
|
||||||
t.Errorf("TryUrlQueryUnescape(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTryUrlPathUnescape(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
expected string
|
|
||||||
}{
|
|
||||||
{"normal.txt", "normal.txt"},
|
|
||||||
{"%E7%8F%80%E5%B0%94%E8%AF%BA.zip", "珀尔诺.zip"},
|
|
||||||
{"hello%20world.txt", "hello world.txt"},
|
|
||||||
{"bad%2-text", "bad%2-text"},
|
|
||||||
// The key difference: %2B should decode to + (not space)
|
|
||||||
{"C%2B%2B%20Primer.txt", "C++ Primer.txt"},
|
|
||||||
{"test%2Bfile.txt", "test+file.txt"},
|
|
||||||
// Plus sign in path should remain as-is (not decoded to space)
|
|
||||||
{"test+plus.txt", "test+plus.txt"},
|
|
||||||
// Mixed encoding
|
|
||||||
{"C%2B%2B%20%20Primer%20%20Plus.mobi", "C++ Primer Plus.mobi"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.input, func(t *testing.T) {
|
|
||||||
got := TryUrlPathUnescape(tt.input)
|
|
||||||
if got != tt.expected {
|
|
||||||
t.Errorf("TryUrlPathUnescape(%q) = %q, want %q", tt.input, got, tt.expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,3 @@
|
|||||||
plugins {
|
|
||||||
id 'com.android.application'
|
|
||||||
id 'kotlin-android'
|
|
||||||
id 'dev.flutter.flutter-gradle-plugin'
|
|
||||||
}
|
|
||||||
|
|
||||||
def localProperties = new Properties()
|
def localProperties = new Properties()
|
||||||
def localPropertiesFile = rootProject.file('local.properties')
|
def localPropertiesFile = rootProject.file('local.properties')
|
||||||
if (localPropertiesFile.exists()) {
|
if (localPropertiesFile.exists()) {
|
||||||
@@ -12,6 +6,11 @@ if (localPropertiesFile.exists()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def flutterRoot = localProperties.getProperty('flutter.sdk')
|
||||||
|
if (flutterRoot == null) {
|
||||||
|
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
|
||||||
|
}
|
||||||
|
|
||||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||||
if (flutterVersionCode == null) {
|
if (flutterVersionCode == null) {
|
||||||
flutterVersionCode = '1'
|
flutterVersionCode = '1'
|
||||||
@@ -22,18 +21,21 @@ if (flutterVersionName == null) {
|
|||||||
flutterVersionName = '1.0'
|
flutterVersionName = '1.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace 'com.gopeed.gopeed'
|
compileSdkVersion 34
|
||||||
compileSdk 35
|
|
||||||
ndkVersion flutter.ndkVersion
|
ndkVersion flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = '17'
|
jvmTarget = '1.8'
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
@@ -42,21 +44,21 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId 'com.gopeed.gopeed'
|
applicationId "com.gopeed.gopeed"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
|
// For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
|
||||||
minSdk flutter.minSdkVersion
|
minSdkVersion 21
|
||||||
targetSdk 35
|
targetSdkVersion 34
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
release {
|
release {
|
||||||
keyAlias 'upload'
|
keyAlias "upload"
|
||||||
keyPassword System.getenv('APK_KEY_PASSWORD')
|
keyPassword System.getenv("APK_KEY_PASSWORD")
|
||||||
storeFile file('upload-keystore.jks')
|
storeFile file("upload-keystore.jks")
|
||||||
storePassword System.getenv('APK_STORE_PASSWORD')
|
storePassword System.getenv("APK_STORE_PASSWORD")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,5 +84,6 @@ flutter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||||
implementation(name: 'libgopeed', ext: 'aar')
|
implementation(name: 'libgopeed', ext: 'aar')
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="com.gopeed.gopeed">
|
||||||
<!-- The INTERNET permission is required for development. Specifically,
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
the Flutter tool needs it to communicate with the running application
|
the Flutter tool needs it to communicate with the running application
|
||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="com.gopeed.gopeed">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
android:maxSdkVersion="32" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
|
||||||
android:maxSdkVersion="32" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
@@ -70,19 +69,6 @@
|
|||||||
<data
|
<data
|
||||||
android:scheme="magnet" />
|
android:scheme="magnet" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<!-- Handle http/https URLs with common downloadable mimeTypes -->
|
|
||||||
<intent-filter android:label="Gopeed">
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data android:scheme="http" />
|
|
||||||
<data android:scheme="https" />
|
|
||||||
<data android:mimeType="*/*" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW_DOWNLOADS" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
the Android process has started. This theme is visible to the user
|
the Android process has started. This theme is visible to the user
|
||||||
@@ -105,7 +91,7 @@
|
|||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- Add android:stopWithTask option only when necessary. -->
|
<!-- Add android:stopWithTask option only when necessary. -->
|
||||||
<service
|
<service
|
||||||
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
|
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
|
||||||
android:foregroundServiceType="dataSync"
|
android:foregroundServiceType="dataSync"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
@@ -116,4 +102,4 @@
|
|||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="com.gopeed.gopeed">
|
||||||
<!-- The INTERNET permission is required for development. Specifically,
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
the Flutter tool needs it to communicate with the running application
|
the Flutter tool needs it to communicate with the running application
|
||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,3 +1,16 @@
|
|||||||
|
buildscript {
|
||||||
|
ext.kotlin_version = '1.8.22'
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:7.1.2'
|
||||||
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
@@ -13,24 +26,6 @@ subprojects {
|
|||||||
project.evaluationDependsOn(':app')
|
project.evaluationDependsOn(':app')
|
||||||
}
|
}
|
||||||
|
|
||||||
subprojects { subproject ->
|
tasks.register("clean", Delete) {
|
||||||
subproject.plugins.withId('com.android.library') {
|
|
||||||
subproject.android {
|
|
||||||
if (namespace == null || namespace.isEmpty()) {
|
|
||||||
def manifest = file("${subproject.projectDir}/src/main/AndroidManifest.xml")
|
|
||||||
if (manifest.exists()) {
|
|
||||||
def manifestContent = manifest.getText()
|
|
||||||
def packagePattern = /package\s*=\s*["']([^"']+)["']/
|
|
||||||
def matcher = (manifestContent =~ packagePattern)
|
|
||||||
if (matcher.find()) {
|
|
||||||
namespace = matcher.group(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.register('clean', Delete) {
|
|
||||||
delete rootProject.buildDir
|
delete rootProject.buildDir
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx1536M
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
android.nonTransitiveRClass=true
|
|
||||||
android.nonFinalResIds=true
|
|
||||||
android.defaults.buildfeatures.buildconfig=true
|
|
||||||
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
|
||||||
@@ -1,25 +1,11 @@
|
|||||||
pluginManagement {
|
|
||||||
def flutterSdkPath = {
|
|
||||||
def properties = new Properties()
|
|
||||||
file('local.properties').withInputStream { properties.load(it) }
|
|
||||||
def flutterSdkPath = properties.getProperty('flutter.sdk')
|
|
||||||
assert flutterSdkPath != null, 'flutter.sdk not set in local.properties'
|
|
||||||
return flutterSdkPath
|
|
||||||
}()
|
|
||||||
|
|
||||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
id 'dev.flutter.flutter-plugin-loader' version '1.0.0'
|
|
||||||
id 'com.android.application' version '8.7.3' apply false
|
|
||||||
id 'org.jetbrains.kotlin.android' version '2.1.0' apply false
|
|
||||||
}
|
|
||||||
|
|
||||||
include ':app'
|
include ':app'
|
||||||
|
|
||||||
|
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
|
||||||
|
def properties = new Properties()
|
||||||
|
|
||||||
|
assert localPropertiesFile.exists()
|
||||||
|
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
|
||||||
|
|
||||||
|
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||||
|
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||||
|
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
|
||||||
+34
-96
@@ -3,24 +3,19 @@ import 'dart:io';
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:dio/io.dart';
|
import 'package:dio/io.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:get/get.dart' as getx;
|
import 'package:gopeed/api/model/extension.dart';
|
||||||
|
import 'package:gopeed/api/model/install_extension.dart';
|
||||||
|
import 'package:gopeed/api/model/switch_extension.dart';
|
||||||
|
|
||||||
import '../app/routes/app_pages.dart';
|
|
||||||
import '../database/database.dart';
|
|
||||||
import '../util/util.dart';
|
import '../util/util.dart';
|
||||||
import 'model/create_task.dart';
|
import 'model/create_task.dart';
|
||||||
import 'model/create_task_batch.dart';
|
|
||||||
import 'model/downloader_config.dart';
|
import 'model/downloader_config.dart';
|
||||||
import 'model/extension.dart';
|
import 'model/request.dart';
|
||||||
import 'model/install_extension.dart';
|
|
||||||
import 'model/login.dart';
|
|
||||||
import 'model/resolve_result.dart';
|
import 'model/resolve_result.dart';
|
||||||
import 'model/resolve_task.dart';
|
|
||||||
import 'model/result.dart';
|
import 'model/result.dart';
|
||||||
import 'model/switch_extension.dart';
|
|
||||||
import 'model/task.dart';
|
import 'model/task.dart';
|
||||||
import 'model/update_check_extension_resp.dart';
|
|
||||||
import 'model/update_extension_settings.dart';
|
import 'model/update_extension_settings.dart';
|
||||||
|
import 'model/update_check_extension_resp.dart';
|
||||||
|
|
||||||
class _Client {
|
class _Client {
|
||||||
static _Client? _instance;
|
static _Client? _instance;
|
||||||
@@ -34,12 +29,12 @@ class _Client {
|
|||||||
_instance = _Client._internal();
|
_instance = _Client._internal();
|
||||||
var dio = Dio();
|
var dio = Dio();
|
||||||
final isUnixSocket = network == 'unix';
|
final isUnixSocket = network == 'unix';
|
||||||
var baseUrl = 'http://127.0.0.1/';
|
var baseUrl = 'http://127.0.0.1';
|
||||||
if (!isUnixSocket) {
|
if (!isUnixSocket) {
|
||||||
if (Util.isWeb()) {
|
if (Util.isWeb()) {
|
||||||
baseUrl = kDebugMode ? 'http://127.0.0.1:9999/' : '';
|
baseUrl = kDebugMode ? 'http://127.0.0.1:9999' : '';
|
||||||
} else {
|
} else {
|
||||||
baseUrl = 'http://$address/';
|
baseUrl = 'http://$address';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dio.options.baseUrl = baseUrl;
|
dio.options.baseUrl = baseUrl;
|
||||||
@@ -47,27 +42,12 @@ class _Client {
|
|||||||
dio.options.sendTimeout = const Duration(seconds: 5);
|
dio.options.sendTimeout = const Duration(seconds: 5);
|
||||||
dio.options.connectTimeout = const Duration(seconds: 5);
|
dio.options.connectTimeout = const Duration(seconds: 5);
|
||||||
dio.options.receiveTimeout = const Duration(seconds: 60);
|
dio.options.receiveTimeout = const Duration(seconds: 60);
|
||||||
dio.interceptors.add(InterceptorsWrapper(
|
dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) {
|
||||||
onRequest: (options, handler) {
|
if (apiToken.isNotEmpty) {
|
||||||
if (apiToken.isNotEmpty) {
|
options.headers['X-Api-Token'] = apiToken;
|
||||||
options.headers['X-Api-Token'] = apiToken;
|
}
|
||||||
}
|
handler.next(options);
|
||||||
if (Util.isWeb()) {
|
}));
|
||||||
final token = Database.instance.getWebToken();
|
|
||||||
if (token != null) {
|
|
||||||
options.headers['Authorization'] = 'Bearer $token';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
handler.next(options);
|
|
||||||
},
|
|
||||||
onError: (error, handler) {
|
|
||||||
// Only web version has a login page
|
|
||||||
if (Util.isWeb() && error.response?.statusCode == 401) {
|
|
||||||
getx.Get.rootDelegate.offAndToNamed(Routes.LOGIN);
|
|
||||||
}
|
|
||||||
handler.next(error);
|
|
||||||
},
|
|
||||||
));
|
|
||||||
|
|
||||||
_instance!.dio = dio;
|
_instance!.dio = dio;
|
||||||
if (isUnixSocket) {
|
if (isUnixSocket) {
|
||||||
@@ -115,37 +95,25 @@ Future<T> _parse<T>(
|
|||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
if (e.type == DioExceptionType.sendTimeout ||
|
if (e.type == DioExceptionType.sendTimeout ||
|
||||||
e.type == DioExceptionType.receiveTimeout ||
|
e.type == DioExceptionType.receiveTimeout ||
|
||||||
e.type == DioExceptionType.connectionTimeout ||
|
e.type == DioExceptionType.connectionTimeout) {
|
||||||
e.type == DioExceptionType.connectionError) {
|
|
||||||
throw TimeoutException("request timeout");
|
throw TimeoutException("request timeout");
|
||||||
}
|
}
|
||||||
throw Exception(Result(code: 1000, msg: e.message));
|
throw Exception(Result(code: 1000, msg: e.message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ResolveResult> resolve(ResolveTask resolveTask) async {
|
Future<ResolveResult> resolve(Request request) async {
|
||||||
return _parse<ResolveResult>(
|
return _parse<ResolveResult>(
|
||||||
() => _client.dio.post("api/v1/resolve", data: resolveTask),
|
() => _client.dio.post("/api/v1/resolve", data: request),
|
||||||
(data) => ResolveResult.fromJson(data));
|
(data) => ResolveResult.fromJson(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> createTask(CreateTask createTask) async {
|
Future<String> createTask(CreateTask createTask) async {
|
||||||
return _parse<String>(
|
return _parse<String>(
|
||||||
() => _client.dio.post("api/v1/tasks", data: createTask),
|
() => _client.dio.post("/api/v1/tasks", data: createTask),
|
||||||
(data) => data as String);
|
(data) => data as String);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<String>> createTaskBatch(CreateTaskBatch createTaskBatch) async {
|
|
||||||
return _parse<List<String>>(
|
|
||||||
() => _client.dio.post("api/v1/tasks/batch", data: createTaskBatch),
|
|
||||||
(data) => (data as List).map((e) => e as String).toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> patchTask(String id, ResolveTask patchTask) async {
|
|
||||||
return _parse(
|
|
||||||
() => _client.dio.patch("api/v1/tasks/$id", data: patchTask), null);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<Task>> getTasks(List<Status> statuses) async {
|
Future<List<Task>> getTasks(List<Status> statuses) async {
|
||||||
return _parse<List<Task>>(
|
return _parse<List<Task>>(
|
||||||
() => _client.dio.get(
|
() => _client.dio.get(
|
||||||
@@ -154,16 +122,16 @@ Future<List<Task>> getTasks(List<Status> statuses) async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> pauseTask(String id) async {
|
Future<void> pauseTask(String id) async {
|
||||||
return _parse(() => _client.dio.put("api/v1/tasks/$id/pause"), null);
|
return _parse(() => _client.dio.put("/api/v1/tasks/$id/pause"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> continueTask(String id) async {
|
Future<void> continueTask(String id) async {
|
||||||
return _parse(() => _client.dio.put("api/v1/tasks/$id/continue"), null);
|
return _parse(() => _client.dio.put("/api/v1/tasks/$id/continue"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> pauseAllTasks(List<String>? ids) async {
|
Future<void> pauseAllTasks(List<String>? ids) async {
|
||||||
return _parse(
|
return _parse(
|
||||||
() => _client.dio.put("api/v1/tasks/pause", queryParameters: {
|
() => _client.dio.put("/api/v1/tasks/pause", queryParameters: {
|
||||||
"id": ids,
|
"id": ids,
|
||||||
}),
|
}),
|
||||||
null);
|
null);
|
||||||
@@ -171,7 +139,7 @@ Future<void> pauseAllTasks(List<String>? ids) async {
|
|||||||
|
|
||||||
Future<void> continueAllTasks(List<String>? ids) async {
|
Future<void> continueAllTasks(List<String>? ids) async {
|
||||||
return _parse(
|
return _parse(
|
||||||
() => _client.dio.put("api/v1/tasks/continue", queryParameters: {
|
() => _client.dio.put("/api/v1/tasks/continue", queryParameters: {
|
||||||
"id": ids,
|
"id": ids,
|
||||||
}),
|
}),
|
||||||
null);
|
null);
|
||||||
@@ -179,12 +147,12 @@ Future<void> continueAllTasks(List<String>? ids) async {
|
|||||||
|
|
||||||
Future<void> deleteTask(String id, bool force) async {
|
Future<void> deleteTask(String id, bool force) async {
|
||||||
return _parse(
|
return _parse(
|
||||||
() => _client.dio.delete("api/v1/tasks/$id?force=$force"), null);
|
() => _client.dio.delete("/api/v1/tasks/$id?force=$force"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteTasks(List<String>? ids, bool force) async {
|
Future<void> deleteTasks(List<String>? ids, bool force) async {
|
||||||
return _parse(
|
return _parse(
|
||||||
() => _client.dio.delete("api/v1/tasks", queryParameters: {
|
() => _client.dio.delete("/api/v1/tasks", queryParameters: {
|
||||||
"id": ids,
|
"id": ids,
|
||||||
"force": force,
|
"force": force,
|
||||||
}),
|
}),
|
||||||
@@ -192,29 +160,29 @@ Future<void> deleteTasks(List<String>? ids, bool force) async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<DownloaderConfig> getConfig() async {
|
Future<DownloaderConfig> getConfig() async {
|
||||||
return _parse(() => _client.dio.get("api/v1/config"),
|
return _parse(() => _client.dio.get("/api/v1/config"),
|
||||||
(data) => DownloaderConfig.fromJson(data));
|
(data) => DownloaderConfig.fromJson(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> putConfig(DownloaderConfig config) async {
|
Future<void> putConfig(DownloaderConfig config) async {
|
||||||
return _parse(() => _client.dio.put("api/v1/config", data: config), null);
|
return _parse(() => _client.dio.put("/api/v1/config", data: config), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> installExtension(InstallExtension installExtension) async {
|
Future<void> installExtension(InstallExtension installExtension) async {
|
||||||
return _parse(
|
return _parse(
|
||||||
() => _client.dio.post("api/v1/extensions", data: installExtension),
|
() => _client.dio.post("/api/v1/extensions", data: installExtension),
|
||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Extension>> getExtensions() async {
|
Future<List<Extension>> getExtensions() async {
|
||||||
return _parse<List<Extension>>(() => _client.dio.get("api/v1/extensions"),
|
return _parse<List<Extension>>(() => _client.dio.get("/api/v1/extensions"),
|
||||||
(data) => (data as List).map((e) => Extension.fromJson(e)).toList());
|
(data) => (data as List).map((e) => Extension.fromJson(e)).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateExtensionSettings(
|
Future<void> updateExtensionSettings(
|
||||||
String identity, UpdateExtensionSettings updateExtensionSettings) async {
|
String identity, UpdateExtensionSettings updateExtensionSettings) async {
|
||||||
return _parse(
|
return _parse(
|
||||||
() => _client.dio.put("api/v1/extensions/$identity/settings",
|
() => _client.dio.put("/api/v1/extensions/$identity/settings",
|
||||||
data: updateExtensionSettings),
|
data: updateExtensionSettings),
|
||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
@@ -223,32 +191,22 @@ Future<void> switchExtension(
|
|||||||
String identity, SwitchExtension switchExtension) async {
|
String identity, SwitchExtension switchExtension) async {
|
||||||
return _parse(
|
return _parse(
|
||||||
() => _client.dio
|
() => _client.dio
|
||||||
.put("api/v1/extensions/$identity/switch", data: switchExtension),
|
.put("/api/v1/extensions/$identity/switch", data: switchExtension),
|
||||||
null);
|
null);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteExtension(String identity) async {
|
Future<void> deleteExtension(String identity) async {
|
||||||
return _parse(() => _client.dio.delete("api/v1/extensions/$identity"), null);
|
return _parse(() => _client.dio.delete("/api/v1/extensions/$identity"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<UpdateCheckExtensionResp> upgradeCheckExtension(String identity) async {
|
Future<UpdateCheckExtensionResp> upgradeCheckExtension(String identity) async {
|
||||||
return _parse(() => _client.dio.get("api/v1/extensions/$identity/update"),
|
return _parse(() => _client.dio.get("/api/v1/extensions/$identity/update"),
|
||||||
(data) => UpdateCheckExtensionResp.fromJson(data));
|
(data) => UpdateCheckExtensionResp.fromJson(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateExtension(String identity) async {
|
Future<void> updateExtension(String identity) async {
|
||||||
return _parse(
|
return _parse(
|
||||||
() => _client.dio.post("api/v1/extensions/$identity/update"), null);
|
() => _client.dio.post("/api/v1/extensions/$identity/update"), null);
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> testWebhook(String url) async {
|
|
||||||
return _parse(
|
|
||||||
() => _client.dio.post("api/v1/webhook/test", data: {"url": url}), null);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String> login(LoginReq loginReq) async {
|
|
||||||
return _parse(() => _client.dio.post("api/web/login", data: loginReq),
|
|
||||||
(data) => data as String);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Response<String>> proxyRequest<T>(String uri,
|
Future<Response<String>> proxyRequest<T>(String uri,
|
||||||
@@ -265,25 +223,5 @@ Future<Response<String>> proxyRequest<T>(String uri,
|
|||||||
}
|
}
|
||||||
|
|
||||||
String join(String path) {
|
String join(String path) {
|
||||||
final baseUrl = _client.dio.options.baseUrl;
|
return "${_client.dio.options.baseUrl}/${Util.cleanPath(path)}";
|
||||||
final cleanBaseUrl = baseUrl.endsWith('/')
|
|
||||||
? baseUrl.substring(0, baseUrl.length - 1)
|
|
||||||
: baseUrl;
|
|
||||||
return "$cleanBaseUrl/${Util.cleanPath(path)}";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generic request method for API proxy
|
|
||||||
/// Directly forwards requests to gopeed REST API
|
|
||||||
Future<Response> forward(
|
|
||||||
String path, {
|
|
||||||
String method = 'GET',
|
|
||||||
dynamic data,
|
|
||||||
Map<String, dynamic>? queryParameters,
|
|
||||||
}) async {
|
|
||||||
return _client.dio.request(
|
|
||||||
path,
|
|
||||||
data: data,
|
|
||||||
queryParameters: queryParameters,
|
|
||||||
options: Options(method: method),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -9,12 +9,12 @@ part 'create_task.g.dart';
|
|||||||
class CreateTask {
|
class CreateTask {
|
||||||
String? rid;
|
String? rid;
|
||||||
Request? req;
|
Request? req;
|
||||||
Options? opts;
|
Options? opt;
|
||||||
|
|
||||||
CreateTask({
|
CreateTask({
|
||||||
this.rid,
|
this.rid,
|
||||||
this.req,
|
this.req,
|
||||||
this.opts,
|
this.opt,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory CreateTask.fromJson(
|
factory CreateTask.fromJson(
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ CreateTask _$CreateTaskFromJson(Map<String, dynamic> json) => CreateTask(
|
|||||||
req: json['req'] == null
|
req: json['req'] == null
|
||||||
? null
|
? null
|
||||||
: Request.fromJson(json['req'] as Map<String, dynamic>),
|
: Request.fromJson(json['req'] as Map<String, dynamic>),
|
||||||
opts: json['opts'] == null
|
opt: json['opt'] == null
|
||||||
? null
|
? null
|
||||||
: Options.fromJson(json['opts'] as Map<String, dynamic>),
|
: Options.fromJson(json['opt'] as Map<String, dynamic>),
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$CreateTaskToJson(CreateTask instance) {
|
Map<String, dynamic> _$CreateTaskToJson(CreateTask instance) {
|
||||||
@@ -27,6 +27,6 @@ Map<String, dynamic> _$CreateTaskToJson(CreateTask instance) {
|
|||||||
|
|
||||||
writeNotNull('rid', instance.rid);
|
writeNotNull('rid', instance.rid);
|
||||||
writeNotNull('req', instance.req?.toJson());
|
writeNotNull('req', instance.req?.toJson());
|
||||||
writeNotNull('opts', instance.opts?.toJson());
|
writeNotNull('opt', instance.opt?.toJson());
|
||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
|
||||||
|
|
||||||
import 'options.dart';
|
|
||||||
import 'request.dart';
|
|
||||||
|
|
||||||
part 'create_task_batch.g.dart';
|
|
||||||
|
|
||||||
@JsonSerializable(explicitToJson: true)
|
|
||||||
class CreateTaskBatch {
|
|
||||||
List<CreateTaskBatchItem>? reqs;
|
|
||||||
Options? opts;
|
|
||||||
|
|
||||||
CreateTaskBatch({
|
|
||||||
this.reqs,
|
|
||||||
this.opts,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory CreateTaskBatch.fromJson(
|
|
||||||
Map<String, dynamic> json,
|
|
||||||
) =>
|
|
||||||
_$CreateTaskBatchFromJson(json);
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => _$CreateTaskBatchToJson(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonSerializable()
|
|
||||||
class CreateTaskBatchItem {
|
|
||||||
Request? req;
|
|
||||||
Options? opts;
|
|
||||||
|
|
||||||
CreateTaskBatchItem({
|
|
||||||
this.req,
|
|
||||||
this.opts,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory CreateTaskBatchItem.fromJson(Map<String, dynamic> json) =>
|
|
||||||
_$CreateTaskBatchItemFromJson(json);
|
|
||||||
Map<String, dynamic> toJson() => _$CreateTaskBatchItemToJson(this);
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'create_task_batch.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// JsonSerializableGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
CreateTaskBatch _$CreateTaskBatchFromJson(Map<String, dynamic> json) =>
|
|
||||||
CreateTaskBatch(
|
|
||||||
reqs: (json['reqs'] as List<dynamic>?)
|
|
||||||
?.map((e) => CreateTaskBatchItem.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList(),
|
|
||||||
opts: json['opts'] == null
|
|
||||||
? null
|
|
||||||
: Options.fromJson(json['opts'] as Map<String, dynamic>),
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> _$CreateTaskBatchToJson(CreateTaskBatch instance) {
|
|
||||||
final val = <String, dynamic>{};
|
|
||||||
|
|
||||||
void writeNotNull(String key, dynamic value) {
|
|
||||||
if (value != null) {
|
|
||||||
val[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeNotNull('reqs', instance.reqs?.map((e) => e.toJson()).toList());
|
|
||||||
writeNotNull('opts', instance.opts?.toJson());
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
|
|
||||||
CreateTaskBatchItem _$CreateTaskBatchItemFromJson(Map<String, dynamic> json) =>
|
|
||||||
CreateTaskBatchItem(
|
|
||||||
req: json['req'] == null
|
|
||||||
? null
|
|
||||||
: Request.fromJson(json['req'] as Map<String, dynamic>),
|
|
||||||
opts: json['opts'] == null
|
|
||||||
? null
|
|
||||||
: Options.fromJson(json['opts'] as Map<String, dynamic>),
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> _$CreateTaskBatchItemToJson(CreateTaskBatchItem instance) {
|
|
||||||
final val = <String, dynamic>{};
|
|
||||||
|
|
||||||
void writeNotNull(String key, dynamic value) {
|
|
||||||
if (value != null) {
|
|
||||||
val[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writeNotNull('req', instance.req);
|
|
||||||
writeNotNull('opts', instance.opts);
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user