Compare commits

..
158 changed files with 2554 additions and 20971 deletions

No files matched your search

+3 -11
View File
@@ -40,24 +40,16 @@ template: |
<table>
<tbody>
<tr>
<td rowspan="4">🪟 Windows</td>
<td rowspan="2"><code>EXE</code></td>
<td rowspan="2">🪟 Windows</td>
<td><code>EXE</code></td>
<td>amd64</td>
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-amd64.zip">📥</a></td>
</tr>
<tr>
<td>arm64</td>
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-arm64.zip">📥</a></td>
</tr>
<tr>
<td rowspan="2"><code>Portable</code></td>
<td><code>Portable</code></td>
<td>amd64</td>
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-amd64-portable.zip">📥</a></td>
</tr>
<tr>
<td>arm64</td>
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-v$NEXT_PATCH_VERSION-windows-arm64-portable.zip">📥</a></td>
</tr>
<tr>
<td rowspan="3">🍎 MacOS</td>
<td rowspan="3"><code>DMG</code></td>
+41 -125
View File
@@ -14,9 +14,7 @@ on:
env:
GO_VERSION: "1.24"
FLUTTER_VERSION: "3.38.7"
GA4_MEASUREMENT_ID: ${{ secrets.GA4_MEASUREMENT_ID }}
GA4_API_SECRET: ${{ secrets.GA4_API_SECRET }}
FLUTTER_VERSION: "3.24.5"
permissions:
contents: write
@@ -36,62 +34,19 @@ jobs:
build-windows:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows' }}
runs-on: windows-latest
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:
- 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
with:
go-version: ${{ env.GO_VERSION }}
- uses: subosito/flutter-action@v2
with:
channel: ${{ matrix.flutter_channel || 'stable' }}
flutter-version: ${{ matrix.flutter_version || env.FLUTTER_VERSION }}
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Build
env:
VERSION: ${{ needs.get-release.outputs.version }}
GOARCH: ${{ matrix.arch }}
run: |
Invoke-WebRequest -Uri "https://github.com/jrsoftware/issrc/raw/main/Files/Languages/Unofficial/ChineseSimplified.isl" -OutFile "ChineseSimplified.isl"
mv ChineseSimplified.isl "C:\Program Files (x86)\Inno Setup 6\Languages\"
@@ -101,30 +56,21 @@ jobs:
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater.exe github.com/GopeedLab/gopeed/cmd/updater
cd ui/flutter
$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"
if ("${{ matrix.arch }}" -eq "amd64") {
# for amd64
$release = "build\windows\x64\runner\Release\"
$mingw = "C:\Program Files\Git\mingw64\bin"
cp $mingw\libstdc++-6.dll $release
cp $mingw\libgcc_s_seh-1.dll $release
} else {
# for ARM64
$release = "build\windows\arm64\runner\Release\"
$mingw = "$env:CLANGARM64_ROOT\aarch64-w64-mingw32\bin"
cp $mingw\libc++.dll $release
cp $mingw\libunwind.dll $release
}
$release = "build\windows\x64\runner\Release\"
cp $mingw\libstdc++-6.dll $release
cp $mingw\libgcc_s_seh-1.dll $release
cp $mingw\libwinpthread-1.dll $release
cp $system\msvcp140.dll $release
cp $system\vcruntime140.dll $release
cp $system\vcruntime140_1.dll $release
New-Item -Path build\windows\Output -ItemType Directory
Compress-Archive -Path "$release*" -DestinationPath "build\windows\Output\Gopeed-$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
echo @"
; Script generated by the Inno Setup Script Wizard.
@@ -170,7 +116,7 @@ jobs:
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[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
[UninstallDelete]
@@ -184,8 +130,9 @@ jobs:
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
"@ > setup.iss
iscc.exe setup.iss
mv "Output\gopeed.exe" "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe"
Compress-Archive -Path "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.exe" -DestinationPath "Output\Gopeed-$TAG-windows-${{ matrix.arch }}.zip"
mv "Output\gopeed.exe" "Output\Gopeed-$TAG-windows-amd64.exe"
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
uses: shogo82148/actions-upload-release-asset@v1
with:
@@ -194,7 +141,7 @@ jobs:
overwrite: true
build-macos-amd64-lib:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos' }}
runs-on: macos-15-intel
runs-on: macos-13
needs: [get-release]
steps:
- uses: actions/checkout@v3
@@ -220,7 +167,7 @@ jobs:
runs-on: macos-latest
strategy:
matrix:
channel: [arm64, amd64, universal]
channel: [ arm64, amd64, universal ]
needs: [get-release, build-macos-amd64-lib]
steps:
- uses: actions/checkout@v3
@@ -251,7 +198,7 @@ jobs:
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
@@ -259,17 +206,17 @@ jobs:
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
@@ -279,7 +226,7 @@ jobs:
VERSION: ${{ needs.get-release.outputs.version }}
run: |
cd ui/flutter
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 }}"
flutter build macos --dart-define="UPDATE_CHANNEL=macosDmg"
cd build/macos/Build/Products/Release
cat>appdmg.json<<EOF
{
@@ -293,7 +240,7 @@ jobs:
EOF
mkdir dist
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
@@ -318,15 +265,24 @@ jobs:
- uses: actions/setup-go@v4
with:
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:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: master
- run: |
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)
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
sudo mv appimagetool /usr/local/bin/
- name: Build
@@ -338,29 +294,6 @@ jobs:
go build -ldflags="-w -s" -o ui/flutter/assets/exec/updater github.com/GopeedLab/gopeed/cmd/updater
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
output: dist/
releases:
@@ -373,8 +306,6 @@ jobs:
build_args:
dart-define:
UPDATE_CHANNEL: linuxAppImage
GA4_MEASUREMENT_ID: ${{ env.GA4_MEASUREMENT_ID }}
GA4_API_SECRET: ${{ env.GA4_API_SECRET }}
- name: deb
package:
platform: linux
@@ -382,22 +313,12 @@ jobs:
build_args:
dart-define:
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
dart pub global activate -sgit https://github.com/GopeedLab/flutter_distributor.git --git-path packages/flutter_distributor
flutter_distributor release --name linux
cd dist/*
# Get current running architecture, x86_64 -> amd64 and aarch64 -> arm64
ARCH="amd64"
if [[ "${{ matrix.os }}" == *-arm ]]; then
ARCH="arm64"
@@ -405,7 +326,6 @@ jobs:
mv gopeed-*-linux.AppImage Gopeed-v${VERSION}-linux-${ARCH}.AppImage
mv gopeed-*-linux.deb Gopeed-v${VERSION}-linux-${ARCH}.deb
mv gopeed-*-linux.rpm Gopeed-v${VERSION}-linux-${ARCH}.rpm
- name: Upload
uses: shogo82148/actions-upload-release-asset@v1
with:
@@ -487,7 +407,6 @@ jobs:
- cmake
- curl
- libgtk-3-dev
- libkeybinder-3.0-dev
- ninja-build
- unzip
- xz-utils
@@ -503,14 +422,13 @@ jobs:
plugin: nil
stage-packages:
- libgtk-3-dev
- libkeybinder-3.0-dev
- libappindicator3-dev
override-build: |
# work around pub get stack overflow # https://github.com/dart-lang/sdk/issues/51068#issuecomment-1396588253
set +e
dart pub get
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/
cp -r build/linux/*/release/bundle/* \$CRAFT_PART_INSTALL/bin/
gpu-2404:
@@ -575,8 +493,8 @@ jobs:
gomobile bind -tags nosqlite -ldflags="-w -s -checklinkname=0 -X github.com/GopeedLab/gopeed/pkg/base.Version=$VERSION" -o ui/flutter/android/app/libs/libgopeed.aar -target=android -androidapi 21 -javapkg=com.gopeed github.com/GopeedLab/gopeed/bind/mobile
cd ui/flutter
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 --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 }}"
flutter build apk --dart-define="UPDATE_CHANNEL=androidApk"
flutter build apk --split-per-abi --dart-define="UPDATE_CHANNEL=androidApk"
mkdir dist
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
@@ -590,7 +508,7 @@ jobs:
overwrite: true
build-ios:
if: ${{ github.event.inputs.platform == 'all' || github.event.inputs.platform == 'ios' }}
runs-on: macos-14
runs-on: macos-latest
needs: [get-release]
steps:
- uses: actions/checkout@v3
@@ -609,7 +527,7 @@ jobs:
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
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
cp -r build/ios/iphoneos/Runner.app Payload
zip -r -y Payload.zip Payload/Runner.app
@@ -638,8 +556,7 @@ jobs:
VERSION: ${{ needs.get-release.outputs.version }}
run: |
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 }}"
dart ../../.github/workflows/scripts/flutter_local_font.dart
flutter build web --no-web-resources-cdn
cd ../../
cp -r ui/flutter/build/web cmd/web/dist
mkdir -p dist/zip
@@ -755,8 +672,7 @@ jobs:
- name: Build flutter web
run: |
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 }}"
dart ../../.github/workflows/scripts/flutter_local_font.dart
flutter build web --no-web-resources-cdn --dart-define="UPDATE_CHANNEL=docker"
cd ../../
rm -rf cmd/web/dist
cp -r ui/flutter/build/web cmd/web/dist
@@ -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);
}
+13 -56
View File
@@ -25,11 +25,10 @@ on:
- ".github/workflows/test.yml"
- "go.mod"
- "go.sum"
workflow_dispatch:
env:
GO_VERSION: "1.24"
FLUTTER_VERSION: "3.38.7"
FLUTTER_VERSION: "3.24.5"
jobs:
# lint:
@@ -54,68 +53,27 @@ jobs:
with:
go-version: ${{ env.GO_VERSION }}
- name: Run Unit tests.
run: |
# 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
run: go test -v -coverpkg=$(go list ./... | grep -v /cmd/ | grep -v /bind/ | paste -sd "," -) -covermode=count -coverprofile=coverage.txt $(go list ./... | grep -v /cmd/ | grep -v /bind/)
- uses: codecov/codecov-action@v4
with:
files: ./coverage.txt
token: ${{ secrets.CODECOV_UPLOAD_TOKEN }}
build-desktop:
name: Build desktop check
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: windows-2022
- os: windows-11-arm
llvm_ver: "20251202" # Only ARM64
flutter_channel: "main"
flutter_version: "7e1c8868"
- os: macos-latest
- os: ubuntu-22.04
- os: ubuntu-22.04-arm
flutter_channel: "main"
name: Build desktop check (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: [test]
os: [ windows-latest, macos-latest, ubuntu-22.04, ubuntu-22.04-arm ]
needs: [ test ]
steps:
- 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
with:
go-version: ${{ env.GO_VERSION }}
- uses: subosito/flutter-action@v2
with:
channel: ${{ matrix.flutter_channel || 'stable' }}
flutter-version: ${{ matrix.flutter_version || env.FLUTTER_VERSION }}
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: master
- if: runner.os == 'Windows'
run: |
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'
run: |
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
cd ui/flutter
flutter build linux
@@ -138,8 +96,8 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-14, ubuntu-latest]
needs: [test]
os: [ macos-latest, ubuntu-latest ]
needs: [ test ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
@@ -171,7 +129,7 @@ jobs:
build-web:
name: Build web check
runs-on: ubuntu-latest
needs: [test]
needs: [ test ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
@@ -184,7 +142,6 @@ jobs:
run: |
cd ui/flutter
flutter build web --no-web-resources-cdn
dart ../../.github/workflows/scripts/flutter_local_font.dart
cd ../../
rm -rf cmd/web/dist
cp -r ui/flutter/build/web cmd/web/dist
@@ -192,7 +149,7 @@ jobs:
build-docker:
name: Build docker check
runs-on: ubuntu-latest
needs: [test]
needs: [ test ]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
+2 -2
View File
@@ -1,4 +1,4 @@
FROM golang:1.24.11-alpine3.23 AS go
FROM golang:1.23.3 AS go
WORKDIR /app
COPY ./go.mod ./go.sum ./
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" \
-o dist/gopeed github.com/GopeedLab/gopeed/cmd/web
FROM alpine:3.23
FROM alpine:3.18
LABEL maintainer="monkeyWie"
WORKDIR /app
COPY --from=go /app/dist/gopeed ./
+5 -20
View File
@@ -5,7 +5,6 @@
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://docs.gopeed.com/donate.html)
[![WeChat](https://img.shields.io/badge/WeChat%20Official%20Account-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
<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,24 +24,16 @@ Visit ✈ [Official Website](https://gopeed.com) | 📖 [Official Docs](https://
<table>
<tbody>
<tr>
<td rowspan="4">🪟 Windows</td>
<td rowspan="2"><code>EXE</code></td>
<td rowspan="2">🪟 Windows</td>
<td><code>EXE</code></td>
<td>amd64</td>
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64.zip">📥</a></td>
</tr>
<tr>
<td>arm64</td>
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-arm64.zip">📥</a></td>
</tr>
<tr>
<td rowspan="2"><code>Portable</code></td>
<td><code>Portable</code></td>
<td>amd64</td>
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-amd64-portable.zip">📥</a></td>
</tr>
<tr>
<td>arm64</td>
<td><a href="https://gopeed.com/api/download?tpl=Gopeed-$version-windows-arm64-portable.zip">📥</a></td>
</tr>
<tr>
<td rowspan="3">🍎 MacOS</td>
<td rowspan="3"><code>DMG</code></td>
@@ -179,12 +170,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)
## 📱 WeChat Official Account
Follow our WeChat Official Account to get the latest updates and news.
<img src="_docs/img/weixin.png" width="200" />
## 💝 Donate
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 +186,8 @@ This project is divided into two parts, the front end uses `flutter`, the back e
### 🌍 Environment
1. Golang 1.24+
2. Flutter 3.38+
1. Golang 1.23+
2. Flutter 3.24+
### 📋 Clone
+2 -9
View File
@@ -5,7 +5,6 @@
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://docs.gopeed.com/donate.html)
[![WeChat](https://img.shields.io/badge/WeChat%20Official%20Account-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
<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>
@@ -160,12 +159,6 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
### 🛠️ コマンドツール
## 📱 WeChat 公式アカウント
公式アカウントをフォローして、最新のアップデートやニュースを入手してください。
<img src="_docs/img/weixin.png" width="200" />
## 💝 寄付
もしこのプロジェクトがお気に召しましたら、このプロジェクトの発展を支援するために[寄付](https://docs.gopeed.com/donate.html)をご検討ください!
@@ -182,8 +175,8 @@ Gopeed (正式名 Go Speed) は `Golang` + `Flutter` によって開発された
### 🌍 環境
1. Go 言語 1.24+
2. Flutter 3.38+
1. Go 言語 1.23+
2. Flutter 3.24+
### 📋 クローン
+2 -9
View File
@@ -5,7 +5,6 @@
[![Phiên bản](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Tải về](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Ủng hộ](https://img.shields.io/badge/%24-ủng%20hộ-ff69b4.svg)](https://docs.gopeed.com/donate.html)
[![WeChat](https://img.shields.io/badge/WeChat%20Official%20Account-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
<a href="https://trendshift.io/repositories/7953" target="_blank"><img src="https://trendshift.io/api/badge/repositories/7953" alt="GopeedLab%2Fgopeed | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -166,12 +165,6 @@ Sử dụng `go install`:
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
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 +181,8 @@ Dự án này được chia thành hai phần, phần giao diện sử dụng `f
### 🌍 Environment
1. Golang 1.24+
2. Flutter 3.38+
1. Golang 1.23+
2. Flutter 3.24+
### 📋 Clone
+2 -9
View File
@@ -5,7 +5,6 @@
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://docs.gopeed.com/zh/donate.html)
[![WeChat](https://img.shields.io/badge/%E5%BE%AE%E4%BF%A1%E5%85%AC%E4%BC%97%E5%8F%B7-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
<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>
@@ -170,12 +169,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)
## 📱 微信公众号
关注公众号获取项目最新动态和资讯。
<img src="_docs/img/weixin.png" width="200" />
## 💝 赞助
如果觉得项目对你有帮助,请考虑[赞助](https://docs.gopeed.com/zh/donate)以支持这个项目的发展,非常感谢!
@@ -192,8 +185,8 @@ Gopeed 还提供了浏览器扩展用于接管浏览器下载,支持 Chrome、
### 🌍 环境要求
1. Golang 1.24+
2. Flutter 3.38+
1. Golang 1.23+
2. Flutter 3.24+
### 📋 克隆项目
+2 -9
View File
@@ -5,7 +5,6 @@
[![Release](https://img.shields.io/github/release/GopeedLab/gopeed.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Download](https://img.shields.io/github/downloads/GopeedLab/gopeed/total.svg)](https://github.com/GopeedLab/gopeed/releases)
[![Donate](https://img.shields.io/badge/%24-donate-ff69b4.svg)](https://docs.gopeed.com/donate.html)
[![WeChat](https://img.shields.io/badge/%E5%BE%AE%E4%BF%A1%E5%85%AC%E4%BC%97%E5%8F%B7-07C160?logo=wechat&logoColor=white)](https://raw.githubusercontent.com/GopeedLab/gopeed/main/_docs/img/weixin.png)
[![Discord](https://img.shields.io/discord/1037992631881449472?label=Discord&logo=discord&style=social)](https://discord.gg/ZUJqJrwCGB)
<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>
@@ -167,12 +166,6 @@ Gopeed(全稱 Go Speed),是一款使用`Golang`+`Flutter`編寫的高速
go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
```
## 📱 微信公眾號
關注公眾號獲取項目最新動態和資訊。
<img src="_docs/img/weixin.png" width="200" />
## 💝 贊助
如果你認為該項目對你有所幫助,請考慮[贊助](https://docs.gopeed.com/zh/donate)以支持該項目的持續發展,謝謝!
@@ -189,8 +182,8 @@ go install github.com/GopeedLab/gopeed/cmd/gopeed@latest
### 🌍 開發環境
1. Golang 1.24+
2. Flutter 3.38+
1. Golang 1.23+
2. Flutter 3.24+
### 📋 克隆項目
Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

-1
View File
@@ -2,7 +2,6 @@ package main
import (
"fmt"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
+2 -3
View File
@@ -2,14 +2,13 @@ package main
import (
"fmt"
"strings"
"sync"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/GopeedLab/gopeed/pkg/util"
"strings"
"sync"
)
const progressWidth = 20
-19
View File
@@ -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"))
}
-11
View File
@@ -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
View File
@@ -1,24 +1,22 @@
package main
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"time"
"strings"
"github.com/pkg/browser"
"github.com/shirou/gopsutil/v4/process"
)
const identifier = "gopeed"
type Message struct {
Method string `json:"method"`
Meta map[string]any `json:"meta"`
Params json.RawMessage `json:"params"`
}
@@ -28,117 +26,33 @@ type Response struct {
Message string `json:"message,omitempty"`
}
func check() (data bool, err error) {
conn, err := Dial()
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
var apiMap = map[string]func(params json.RawMessage) (data any, err error){
"ping": func(params json.RawMessage) (data any, err error) {
processes, err := process.Processes()
if err != nil {
return false, err
}
time.Sleep(1 * time.Second)
}
return fmt.Errorf("start gopeed failed")
}
// postToFlutter sends a POST request to Flutter RPC server
func postToFlutter(path string, body []byte, headers map[string]string, timeout time.Duration) (*http.Response, error) {
client := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return Dial()
},
},
Timeout: timeout,
}
req, err := http.NewRequest("POST", "http://127.0.0.1"+path, bytes.NewBuffer(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
return client.Do(req)
}
for _, p := range processes {
name, err := p.Name()
if err != nil {
continue
}
var apiMap = map[string]func(message *Message) (data any, err error){
"ping": func(message *Message) (data any, err error) {
return check()
if strings.Contains(strings.ToLower(name), strings.ToLower(identifier)) {
return true, nil
}
}
return false, nil
},
"wakeup": func(message *Message) (data any, err error) {
silent := false
if v, ok := message.Meta["silent"]; ok {
silent, _ = v.(bool)
"create": func(params json.RawMessage) (data any, err error) {
var strParams string
if err = json.Unmarshal(params, &strParams); err != nil {
return
}
err = wakeup(silent)
err = browser.OpenURL(fmt.Sprintf("%s:///create?params=%s", identifier, strParams))
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
@@ -149,6 +63,7 @@ func main() {
var length uint32
if err := binary.Read(os.Stdin, binary.NativeEndian, &length); err != nil {
if err == io.EOF {
// Connection closed by Chrome
return
}
sendError("Failed to read message length: " + err.Error())
@@ -173,7 +88,7 @@ func main() {
var data any
var err error
if handler, ok := apiMap[message.Method]; ok {
data, err = handler(&message)
data, err = handler(message.Params)
} else {
err = errors.New("Unknown method: " + message.Method)
}
+6 -5
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"os"
"path/filepath"
"syscall"
"time"
@@ -18,7 +19,6 @@ func main() {
pid := flag.Int("pid", 0, "PID of the process to update")
updateChannel := flag.String("channel", "", "Update channel")
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")
flag.Parse()
@@ -43,7 +43,7 @@ func main() {
restart bool
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)
os.Exit(1)
}
@@ -61,7 +61,7 @@ func main() {
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)
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)
if restart, err = install(killSignalChan, updateChannel, packagePath, exeDir); err != nil {
appDir := filepath.Dir(os.Args[0])
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")
}
+5 -9
View File
@@ -25,20 +25,16 @@ func installByDmg(killSignalChan chan<- any, packagePath, destDir string) error
mountPoint := ""
for _, line := range strings.Split(string(output), "\n") {
if strings.Contains(line, "/Volumes/") {
// Find the /Volumes/ path in the line
// hdiutil output format: /dev/disk4s1 Apple_HFS /Volumes/Gopeed
// or with sequence number: /dev/disk4s1 Apple_HFS /Volumes/Gopeed 1
idx := strings.Index(line, "/Volumes/")
if idx != -1 {
// Extract everything from /Volumes/ onwards and trim whitespace
mountPoint = strings.TrimSpace(line[idx:])
fields := strings.Fields(line)
if len(fields) > 2 {
mountPoint = fields[len(fields)-1]
break
}
}
}
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
@@ -49,7 +45,7 @@ func installByDmg(killSignalChan chan<- any, packagePath, destDir string) error
return err
}
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
+51 -166
View File
@@ -3,23 +3,19 @@ package main
import (
"encoding/json"
"flag"
"fmt"
"github.com/GopeedLab/gopeed/pkg/base"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"github.com/GopeedLab/gopeed/pkg/base"
)
type args struct {
Address *string `json:"address"`
Port *int `json:"port"`
Username *string `json:"username"`
Password *string `json:"password"`
ApiToken *string `json:"apiToken"`
StorageDir *string `json:"storageDir"`
WhiteDownloadDirs []string `json:"whiteDownloadDirs"`
Address *string `json:"address"`
Port *int `json:"port"`
Username *string `json:"username"`
Password *string `json:"password"`
ApiToken *string `json:"apiToken"`
StorageDir *string `json:"storageDir"`
// DownloadConfig when the first time to start the server, it will be configured as initial value
DownloadConfig *base.DownloaderStoreConfig `json:"downloadConfig"`
@@ -27,173 +23,62 @@ type args struct {
}
func parse() *args {
cfg := &args{}
cliConfig := loadCliArgs()
loadConfigFile(cfg, *cliConfig.configPath)
loadEnvVars(cfg)
// override with non-default command line arguments
overrideWithCliArgs(cfg, cliConfig)
// set default values
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")
var cliArgs args
cliArgs.Address = flag.String("A", "0.0.0.0", "Bind Address")
cliArgs.Port = flag.Int("P", 9999, "Bind Port")
cliArgs.Username = flag.String("u", "gopeed", "Web Authentication Username")
cliArgs.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
cliArgs.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
cliArgs.StorageDir = flag.String("d", "", "Storage directory")
cliArgs.configPath = flag.String("c", "./config.json", "Config file path")
flag.Parse()
// Parse white download directories from comma-separated string
if whiteDownloadDirs != nil && *whiteDownloadDirs != "" {
dirs := strings.Split(*whiteDownloadDirs, ",")
for i := range dirs {
dirs[i] = strings.TrimSpace(dirs[i])
}
cfg.WhiteDownloadDirs = dirs
// args priority: config file > cli args
cfgArgs := loadConfig(*cliArgs.configPath)
if cfgArgs.Address == nil {
cfgArgs.Address = cliArgs.Address
}
return cfg
if cfgArgs.Port == nil {
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 overrideWithCliArgs(cfg *args, cliConfig *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
}
})
}
func loadConfig(path string) *args {
var args args
// setDefaults sets default values for any unset configuration fields
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) {
if !filepath.IsAbs(path) {
dir, err := os.Getwd()
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(configPath)
file, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return
return &args
}
return
fmt.Println("config file read failed, reason:" + err.Error())
return &args
}
if err = json.Unmarshal(file, cfg); err != nil {
return
}
}
// 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))
}
}
if err = json.Unmarshal(file, &args); err != nil {
fmt.Println("config file parse failed, reason:" + err.Error())
return &args
}
fmt.Printf("config file loaded: %s\n", path)
return &args
}
File diff suppressed because it is too large. Load diff
+15 -17
View File
@@ -6,12 +6,11 @@ package main
import (
"embed"
"fmt"
"github.com/GopeedLab/gopeed/cmd"
"github.com/GopeedLab/gopeed/pkg/rest/model"
"io/fs"
"os"
"path/filepath"
"github.com/GopeedLab/gopeed/cmd"
"github.com/GopeedLab/gopeed/pkg/rest/model"
)
//go:embed dist/*
@@ -32,29 +31,28 @@ func main() {
}
}
var storageDir string
var dir string
if args.StorageDir != nil && *args.StorageDir != "" {
storageDir = *args.StorageDir
dir = *args.StorageDir
} else {
exe, err := os.Executable()
if err != nil {
panic(err)
}
storageDir = filepath.Join(filepath.Dir(exe), "storage")
dir = filepath.Dir(exe)
}
cfg := &model.StartConfig{
Network: "tcp",
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
Storage: model.StorageBolt,
StorageDir: storageDir,
WhiteDownloadDirs: args.WhiteDownloadDirs,
ApiToken: *args.ApiToken,
DownloadConfig: args.DownloadConfig,
ProductionMode: true,
WebEnable: true,
WebFS: sub,
WebAuth: webAuth,
Network: "tcp",
Address: fmt.Sprintf("%s:%d", *args.Address, *args.Port),
Storage: model.StorageBolt,
StorageDir: filepath.Join(dir, "storage"),
ApiToken: *args.ApiToken,
DownloadConfig: args.DownloadConfig,
ProductionMode: true,
WebEnable: true,
WebFS: sub,
WebAuth: webAuth,
}
cmd.Start(cfg)
}
+67 -79
View File
@@ -1,14 +1,14 @@
module github.com/GopeedLab/gopeed
go 1.24.9
go 1.23.0
toolchain go1.24.11
toolchain go1.23.1
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/bodgit/sevenzip v1.6.1
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
github.com/dop251/goja v0.0.0-20240919115326-6c7d1df7ff05
github.com/dop251/goja_nodejs v0.0.0-20240728170619-29b559befffc
github.com/go-git/go-git/v5 v5.8.1
github.com/gorilla/handlers v1.5.1
@@ -16,149 +16,137 @@ require (
github.com/imroc/req/v3 v3.52.2
github.com/matoous/go-nanoid/v2 v2.0.0
github.com/mattn/go-ieproxy v0.0.12
github.com/mholt/archives v0.1.5
github.com/pkg/errors v0.9.1
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529
github.com/rs/zerolog v1.31.0
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad
go.etcd.io/bbolt v1.4.3
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93
go.etcd.io/bbolt v1.3.11
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f
golang.org/x/sync v0.13.0
)
require (
github.com/STARRY-S/zip v0.2.3 // indirect
github.com/anacrolix/btree v0.1.1 // indirect
github.com/anacrolix/missinggo/v2 v2.10.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/felixge/fgprof v0.9.5 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/ebitengine/purego v0.8.1 // indirect
github.com/go-ole/go-ole v1.2.6 // 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/lufia/plan9stats v0.0.0-20220913051719-115f729f3c8c // 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/power-devops/perfstat v0.0.0-20220216144756-c35f1ee13d7c // 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
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // 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 (
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/RoaringBitmap/roaring v1.9.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/anacrolix/chansync v0.7.0 // indirect
github.com/anacrolix/dht/v2 v2.23.0 // indirect
github.com/anacrolix/envpprof v1.5.0 // indirect
github.com/anacrolix/generics v0.2.0 // indirect
github.com/anacrolix/go-libutp v1.3.2 // indirect
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb // indirect
github.com/anacrolix/chansync v0.6.0 // indirect
github.com/anacrolix/dht/v2 v2.22.0 // indirect
github.com/anacrolix/envpprof v1.4.0 // indirect
github.com/anacrolix/generics v0.0.3-0.20240902042256-7fb2702ef0ca // indirect
github.com/anacrolix/go-libutp v1.3.1 // indirect
github.com/anacrolix/log v0.16.0 // indirect
github.com/anacrolix/missinggo v1.3.0 // indirect
github.com/anacrolix/missinggo/perf v1.0.0 // indirect
github.com/anacrolix/mmsg v1.1.1 // indirect
github.com/anacrolix/multiless v0.4.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/utp v0.2.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // 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/cespare/xxhash v1.1.0 // indirect
github.com/cloudflare/circl v1.6.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/edsrzf/mmap-go v1.2.0 // indirect
github.com/emirpasic/gods v1.18.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/go-billy/v5 v5.4.1 // indirect
github.com/go-llsqlite/adapter v0.2.0 // indirect
github.com/go-llsqlite/crawshaw v0.6.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-llsqlite/adapter v0.1.0 // indirect
github.com/go-llsqlite/crawshaw v0.5.5 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // 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-20250423184734-337e5dd93bb4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // 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-isatty v0.0.20 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/multiformats/go-multihash v0.2.3 // indirect
github.com/multiformats/go-varint v0.1.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v3 v3.0.10 // indirect
github.com/pion/ice/v4 v4.2.0 // indirect
github.com/pion/interceptor v0.1.42 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/multiformats/go-varint v0.0.7 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pion/datachannel v1.5.9 // indirect
github.com/pion/dtls/v3 v3.0.4 // indirect
github.com/pion/ice/v4 v4.0.2 // indirect
github.com/pion/interceptor v0.1.37 // indirect
github.com/pion/logging v0.2.2 // indirect
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/rtp v1.10.0 // indirect
github.com/pion/sctp v1.9.1 // indirect
github.com/pion/sdp/v3 v3.0.17 // indirect
github.com/pion/srtp/v3 v3.0.10 // indirect
github.com/pion/stun/v3 v3.1.1 // indirect
github.com/pion/turn/v4 v4.1.4 // indirect
github.com/pion/webrtc/v4 v4.2.2-0.20260109001657-a5962f314db7 // indirect
github.com/pion/rtcp v1.2.14 // indirect
github.com/pion/rtp v1.8.9 // indirect
github.com/pion/sctp v1.8.33 // indirect
github.com/pion/sdp/v3 v3.0.9 // indirect
github.com/pion/srtp/v3 v3.0.4 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // 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/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/protolambda/ctxlock v0.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/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/xanzy/ssh-agent v0.3.3 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.40.0 // indirect
go.opentelemetry.io/otel v1.32.0 // indirect
go.opentelemetry.io/otel/metric v1.32.0 // indirect
go.opentelemetry.io/otel/trace v1.32.0 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/time v0.8.0 // indirect
golang.org/x/tools v0.32.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
lukechampine.com/blake3 v1.4.1 // indirect
modernc.org/libc v1.67.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.43.0 // indirect
zombiezen.com/go/sqlite v1.4.2 // indirect
lukechampine.com/blake3 v1.3.0 // indirect
modernc.org/libc v1.61.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.33.1 // indirect
zombiezen.com/go/sqlite v1.4.0 // indirect
)
+231 -350
View File
File diff suppressed because it is too large. Load diff
+6 -8
View File
@@ -1,23 +1,21 @@
package fetcher
import (
"path"
"strings"
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/pkg/base"
"path"
"strings"
)
// Fetcher defines the interface for a download protocol.
// Each download task will have a corresponding Fetcher instance for the management of the download task
type Fetcher interface {
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
// 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
Close() error
+32 -84
View File
@@ -20,15 +20,15 @@ import (
"github.com/GopeedLab/gopeed/pkg/util"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/metainfo"
"github.com/anacrolix/torrent/storage"
)
var (
cfg *torrent.ClientConfig
client *torrent.Client
lock sync.Mutex
closeCtx context.Context
closeFunc func()
client *torrent.Client
lock sync.Mutex
torrentDirMap = make(map[string]string)
ftMap = make(map[string]*fileTorrentImpl)
closeCtx context.Context
closeFunc func()
)
type Fetcher struct {
@@ -71,12 +71,21 @@ func (f *Fetcher) initClient() (err error) {
closeCtx, closeFunc = context.WithCancel(context.Background())
}
cfg = torrent.NewDefaultClientConfig()
cfg := torrent.NewDefaultClientConfig()
cfg.Seed = true
cfg.Bep20 = fmt.Sprintf("-GP%s-", parseBep20())
cfg.ExtendedHandshakeClientVersion = fmt.Sprintf("Gopeed %s", base.Version)
cfg.ListenPort = f.config.ListenPort
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}
cfg.TrackerDialContext = dnsResolver.DialContext
client, err = torrent.NewClient(cfg)
@@ -91,12 +100,8 @@ func (f *Fetcher) initClient() (err error) {
return
}
func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
func (f *Fetcher) Resolve(req *base.Request) error {
f.meta.Req = req
f.meta.Opts = opts
if f.meta.Opts == nil {
f.meta.Opts = &base.Options{}
}
if err := f.addTorrent(req, false); err != nil {
return err
}
@@ -104,13 +109,23 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error {
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) {
if !f.torrentReady.Load() {
if err = f.addTorrent(f.meta.Req, false); err != nil {
return
}
}
if ft, ok := ftMap[f.meta.Res.Hash]; ok {
ft.setTorrentDir(f.meta.Opts.Path)
}
files := f.torrent.Files()
// If the user does not specify the file to download, all files will be downloaded by default
if f.data.Progress == nil {
@@ -130,12 +145,12 @@ func (f *Fetcher) Start() (err error) {
file.Download()
}
}
f.torrent.AllowDataDownload()
return
}
func (f *Fetcher) Pause() (err error) {
f.torrent.DisallowDataDownload()
f.torrentReady.Store(false)
f.safeDrop()
return
}
@@ -232,59 +247,6 @@ func (f *Fetcher) isDone() bool {
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() {
res := &base.Resource{
Range: true,
@@ -399,12 +361,8 @@ func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
}
schema := util.ParseSchema(req.URL)
privateTorrent := false
var spec *torrent.TorrentSpec
if schema == "MAGNET" {
spec, err = torrent.TorrentSpecFromMagnetUri(req.URL)
if err != nil {
return
}
f.torrent, err = client.AddMagnet(req.URL)
} else {
var reader io.Reader
if schema == "FILE" {
@@ -441,18 +399,8 @@ func (f *Fetcher) addTorrent(req *base.Request, fromUpload bool) (err error) {
if info.Private != nil && *info.Private {
privateTorrent = true
}
spec, err = torrent.TorrentSpecFromMetaInfoErr(metaInfo)
if err != nil {
return
}
f.torrent, err = client.AddTorrent(metaInfo)
}
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 {
return
}
+10 -138
View File
@@ -3,18 +3,17 @@ package bt
import (
"encoding/base64"
"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"
"net/url"
"os"
"path/filepath"
"reflect"
"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) {
@@ -31,7 +30,7 @@ func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
dataUri := "data:application/x-bittorrent;base64," + base64.StdEncoding.EncodeToString(buf)
err = fetcher.Resolve(&base.Request{
URL: dataUri,
}, nil)
})
if err != nil {
panic(err)
}
@@ -81,7 +80,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
"udp://tracker.bitsearch.to:1337/announce",
},
},
}, nil)
})
if err != nil {
panic(err)
}
@@ -111,7 +110,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
"udp://tracker.bitsearch.to:1337/announce",
},
},
}, nil)
})
if err != nil {
panic(err)
}
@@ -145,7 +144,7 @@ func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
t.Run("Resolve Unclean Torrent", func(t *testing.T) {
err := fetcher.Resolve(&base.Request{
URL: "./testdata/test.unclean.torrent",
}, nil)
})
if err != nil {
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
err := fetcher.Resolve(&base.Request{
URL: uri,
}, nil)
})
if err != nil {
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)
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))
}
})
}
+215
View File
@@ -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
}
+36
View File
@@ -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
}
+61
View File
@@ -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)
}
File diff suppressed because it is too large. Load diff
-105
View File
@@ -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 &amp; and then truncated at the semicolon
func TestParseFilenameWithAmpersand(t *testing.T) {
tests := []struct {
name string
disposition string
want string
}{
{
name: "quoted filename with &amp;",
disposition: `attachment; filename="查询处理&amp;优化.pptx"`,
want: "查询处理&优化.pptx",
},
{
name: "unquoted filename with &amp;",
disposition: `attachment; filename=test&amp;file.txt`,
want: "test&file.txt",
},
{
name: "quoted filename with &amp; and extra params",
disposition: `attachment; filename="test&amp;file.txt"; charset=utf-8`,
want: "test&file.txt",
},
{
name: "filename with multiple HTML entities",
disposition: `attachment; filename="test&amp;&lt;&gt;.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&amp;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&amp;file.txt",
want: "test&file.txt",
},
{
name: "less than and greater than",
in: "&lt;test&gt;.txt",
want: "<test>.txt",
},
{
name: "quote",
in: "test&quot;file.txt",
want: "test\"file.txt",
},
{
name: "multiple entities",
in: "a&amp;b&lt;c&gt;d&quot;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)
}
})
}
}
-474
View File
@@ -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., &amp; -> &, &lt; -> <, &gt; -> >)
// 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 &amp;
func unescapeHTMLEntities(s string) string {
// Common HTML entities that might appear in filenames
replacements := map[string]string{
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": "\"",
"&#39;": "'",
"&apos;": "'",
}
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., &amp; 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., &amp; &lt; &gt; &quot; &#39;)
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 &amp; &lt; &#39; 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
}
+17 -535
View File
File diff suppressed because it is too large. Load diff
+11 -71
View File
@@ -2,14 +2,13 @@ package base
import (
"fmt"
"github.com/GopeedLab/gopeed/pkg/util"
"github.com/mattn/go-ieproxy"
"golang.org/x/exp/slices"
"net/http"
"net/url"
"sync"
"time"
"github.com/GopeedLab/gopeed/pkg/util"
"github.com/mattn/go-ieproxy"
"golang.org/x/exp/slices"
)
// Request download request
@@ -78,7 +77,7 @@ func (r *Resource) Validate() error {
if r.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")
}
for _, file := range r.Files {
@@ -124,7 +123,7 @@ func (o *Options) InitSelectFiles(fileSize int) {
// if selectFiles is empty, select all files
if len(o.SelectFiles) == 0 {
o.SelectFiles = make([]int, fileSize)
for i := range fileSize {
for i := 0; i < fileSize; i++ {
o.SelectFiles[i] = i
}
}
@@ -149,7 +148,7 @@ func ParseReqExtra[E any](req *Request) error {
return nil
}
func ParseOptExtra[E any](opts *Options) error {
func ParseOptsExtra[E any](opts *Options) error {
if opts.Extra == nil {
return nil
}
@@ -178,16 +177,11 @@ type CreateTaskBatchItem struct {
type DownloaderStoreConfig struct {
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
MaxRunning int `json:"maxRunning"` // MaxRunning is the max running download count
ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
Extra map[string]any `json:"extra"`
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
DownloadDir string `json:"downloadDir"` // DownloadDir is the default directory to save the downloaded files
MaxRunning int `json:"maxRunning"` // MaxRunning is the max running download count
ProtocolConfig map[string]any `json:"protocolConfig"` // ProtocolConfig is special config for each protocol
Extra map[string]any `json:"extra"`
Proxy *DownloaderProxyConfig `json:"proxy"`
}
func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
@@ -200,24 +194,6 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig {
if cfg.Proxy == nil {
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
}
@@ -240,45 +216,9 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
if cfg.Proxy == nil {
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
}
// 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 {
Enable bool `json:"enable"`
// System is the flag that use system proxy
+1 -174
View File
@@ -18,16 +18,6 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
MaxRunning: 5,
ProtocolConfig: map[string]any{},
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,
ProtocolConfig: map[string]any{},
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{
"key": "value",
},
Proxy: &DownloaderProxyConfig{},
Webhook: &WebhookConfig{},
Script: &ScriptConfig{},
AutoTorrent: &AutoTorrentConfig{
Enable: false,
DeleteAfterDownload: false,
},
Archive: &ArchiveConfig{
AutoExtract: false,
DeleteAfterExtract: false,
},
Proxy: &DownloaderProxyConfig{},
},
},
{
@@ -89,64 +59,6 @@ func TestDownloaderStoreConfig_Init(t *testing.T) {
Proxy: &DownloaderProxyConfig{
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,
Extra: tt.fields.Extra,
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) {
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 {
t.Run(tt.name, func(t *testing.T) {
@@ -425,9 +255,6 @@ func TestDownloaderStoreConfig_Merge(t *testing.T) {
ProtocolConfig: tt.fields.ProtocolConfig,
Extra: tt.fields.Extra,
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) {
t.Errorf("Merge() = %v, want %v", got, tt.want)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+4 -6
View File
@@ -131,7 +131,6 @@ type XMLHttpRequest struct {
responseHeaders http.Header
aborted bool
client *req.Client
fingerprint string
WithCredentials bool `json:"withCredentials"`
Upload *XMLHttpRequestUpload `json:"upload"`
@@ -165,7 +164,7 @@ func (xhr *XMLHttpRequest) SetRequestHeader(key, value string) {
}
func (xhr *XMLHttpRequest) Send(data goja.Value) {
setFingerprint(xhr.client, xhr.fingerprint)
setFingerprint(xhr.client)
d := xhr.parseData(data)
var (
@@ -382,8 +381,7 @@ func Enable(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL,
}
instance := &XMLHttpRequest{
client: client,
fingerprint: util.SafeGet[string](runtime, FingerprintMagicKey),
client: client,
Upload: &XMLHttpRequestUpload{
EventProp: &EventProp{
eventListeners: make(map[string]func(event *ProgressEvent)),
@@ -397,8 +395,8 @@ func Enable(runtime *goja.Runtime, proxyHandler func(r *http.Request) (*url.URL,
instanceValue.SetPrototype(call.This.Prototype())
return instanceValue
})
runtime.Set("__gopeed_setFingerprint", func(fingerprint string) {
runtime.Set(FingerprintMagicKey, fingerprint)
runtime.Set("__gopeed_setFingerprint", func(fingerprint Fingerprint) {
currentFingerprint = fingerprint
})
if err := runtime.Set("ProgressEvent", progressEvent); err != nil {
return err
@@ -1,19 +1,21 @@
package xhr
import "github.com/imroc/req/v3"
import (
"github.com/imroc/req/v3"
)
type Fingerprint string
const (
FingerprintMagicKey = "__gopeed_xhr_fingerprint"
fingerprintChrome = "chrome"
fingerprintFirefox = "firefox"
fingerprintSafari = "safari"
fingerprintChrome Fingerprint = "chrome"
fingerprintFirefox Fingerprint = "firefox"
fingerprintSafari Fingerprint = "safari"
)
func setFingerprint(client *req.Client, fingerprint string) {
switch fingerprint {
var currentFingerprint Fingerprint = ""
func setFingerprint(client *req.Client) {
switch currentFingerprint {
case fingerprintChrome:
client.ImpersonateChrome()
case fingerprintFirefox:
+6 -6
View File
@@ -217,7 +217,7 @@
"@webassemblyjs/helper-wasm-bytecode": "1.11.1",
"@webassemblyjs/helper-wasm-section": "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/wast-printer": "1.11.1"
}
@@ -235,9 +235,9 @@
"@webassemblyjs/utf8": "1.11.1"
}
},
"node_modules/@webassemblyjs/wasm-opts": {
"node_modules/@webassemblyjs/wasm-opt": {
"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==",
"dev": true,
"dependencies": {
@@ -2114,7 +2114,7 @@
"@webassemblyjs/helper-wasm-bytecode": "1.11.1",
"@webassemblyjs/helper-wasm-section": "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/wast-printer": "1.11.1"
}
@@ -2132,9 +2132,9 @@
"@webassemblyjs/utf8": "1.11.1"
}
},
"@webassemblyjs/wasm-opts": {
"@webassemblyjs/wasm-opt": {
"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==",
"dev": true,
"requires": {
-13
View File
@@ -22,16 +22,3 @@ func AssertError[T error](err error) (t T, r bool) {
}
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
View File
@@ -3,14 +3,6 @@ package download
import (
"encoding/json"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/GopeedLab/gopeed/internal/logger"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download/engine"
@@ -20,6 +12,13 @@ import (
"github.com/dop251/goja"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/transport"
"io"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
)
var (
@@ -201,14 +200,10 @@ func (d *Downloader) fetchExtensionByGit(url string, handler func(tempExtPath st
subPath = pathArr[1]
}
// Use unique suffix to avoid concurrent conflicts when multiple git operations
// target the same extension (e.g., install and update check happening simultaneously)
tempExtDir := filepath.Join(d.cfg.StorageDir, tempExtensionsDir, fmt.Sprintf("%s_%d", projectPath, time.Now().UnixNano()))
if err := os.MkdirAll(tempExtDir, 0755); err != nil {
tempExtDir := filepath.Join(d.cfg.StorageDir, tempExtensionsDir, projectPath)
if err := util.RmAndMkDirAll(tempExtDir); err != nil {
return nil, err
}
defer os.RemoveAll(tempExtDir)
proxyOptions := transport.ProxyOptions{}
proxyUrl := d.cfg.DownloaderStoreConfig.Proxy.ToUrl()
if proxyUrl != nil {
@@ -225,6 +220,7 @@ func (d *Downloader) fetchExtensionByGit(url string, handler func(tempExtPath st
}); err != nil {
return nil, err
}
defer os.RemoveAll(tempExtDir)
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())
return
}
ctx.Res.Name = util.SafeFilename(ctx.Res.Name)
ctx.Res.Name = util.ReplaceInvalidFilename(ctx.Res.Name)
for _, file := range ctx.Res.Files {
file.Name = util.SafeFilename(file.Name)
file.Name = util.ReplaceInvalidFilename(file.Name)
}
ctx.Res.CalcSize(nil)
}
+18 -19
View File
@@ -2,14 +2,13 @@ package download
import (
"errors"
"os"
"testing"
"time"
"github.com/GopeedLab/gopeed/internal/logger"
"github.com/GopeedLab/gopeed/pkg/base"
gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error"
"github.com/dop251/goja"
"os"
"testing"
"time"
)
func TestDownloader_InstallExtensionByFolder(t *testing.T) {
@@ -19,7 +18,7 @@ func TestDownloader_InstallExtensionByFolder(t *testing.T) {
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -36,7 +35,7 @@ func TestDownloader_InstallExtensionByFolderDevMode(t *testing.T) {
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -53,7 +52,7 @@ func TestDownloader_InstallExtensionByGit(t *testing.T) {
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/GopeedLab/gopeed/releases",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -70,7 +69,7 @@ func TestDownloader_InstallExtensionByGitSimple(t *testing.T) {
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/GopeedLab/gopeed/releases",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -87,7 +86,7 @@ func TestDownloader_InstallExtensionByGitFull(t *testing.T) {
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/GopeedLab/gopeed/releases",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -157,7 +156,7 @@ func TestDownloader_UpgradeExtension(t *testing.T) {
rr, err := downloader.Resolve(&base.Request{
URL: "https://test.com",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -186,7 +185,7 @@ func TestDownloader_Extension_OnStart(t *testing.T) {
select {
case err = <-errCh:
break
case <-time.After(time.Second * 30): // Increased timeout for real network requests
case <-time.After(time.Second * 10):
err = errors.New("timeout")
}
if err != nil {
@@ -239,7 +238,7 @@ func TestDownloader_Extension_OnError(t *testing.T) {
select {
case err = <-errCh:
break
case <-time.After(time.Second * 30): // Increased timeout for real network requests
case <-time.After(time.Second * 10):
err = errors.New("timeout")
}
@@ -274,7 +273,7 @@ func TestDownloader_Extension_OnDone(t *testing.T) {
select {
case err = <-errCh:
break
case <-time.After(time.Second * 30): // Increased timeout for real network requests
case <-time.After(time.Second * 10):
err = errors.New("timeout")
}
// wait for script execution
@@ -301,7 +300,7 @@ func TestDownloader_Extension_Errors(t *testing.T) {
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -316,7 +315,7 @@ func TestDownloader_Extension_Errors(t *testing.T) {
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -331,7 +330,7 @@ func TestDownloader_Extension_Errors(t *testing.T) {
}
_, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
}, nil)
})
if err == 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{
URL: "https://github.com/test",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -374,7 +373,7 @@ func TestDownloader_Extension_Settings(t *testing.T) {
})
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
}, nil)
})
if err != nil {
t.Fatal(err)
}
@@ -391,7 +390,7 @@ func TestDownloader_ExtensionStorage(t *testing.T) {
}
rr, err := downloader.Resolve(&base.Request{
URL: "https://github.com/test",
}, nil)
})
if err != nil {
t.Fatal(err)
}
-479
View File
@@ -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)
}
-92
View File
@@ -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
}
-199
View File
@@ -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
}
File diff suppressed because it is too large. Load diff
-46
View File
@@ -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))
}
Loaded 50 of 158 files, more files were not shown because too many files have changed in this diff. Show more