mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-28 14:58:59 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b4dfa7580 | ||
|
|
9b35c6dc42 | ||
|
|
1c95109b7b | ||
|
|
d4c38b7550 | ||
|
|
c681b891eb | ||
|
|
c19477a4f4 | ||
|
|
a7069a8f91 | ||
|
|
bb127d1cc2 | ||
|
|
927ca6e4ca | ||
|
|
66cf998fb2 | ||
|
|
37db310157 | ||
|
|
13fe8b4593 | ||
|
|
a5f96b57e6 | ||
|
|
f0a7a043ec | ||
|
|
58249d6a13 | ||
|
|
d9dc7ddf3a | ||
|
|
cc1ad1552c | ||
|
|
a3feb39e20 | ||
|
|
7676579659 | ||
|
|
bcb3729fa9 | ||
|
|
49a824c171 | ||
|
|
2a050f113b | ||
|
|
e4b99dd44b | ||
|
|
18584ab81f | ||
|
|
a4f7360336 | ||
|
|
16b44d2d3a | ||
|
|
71c08501e7 | ||
|
|
297cddf31d | ||
|
|
57eef4311f | ||
|
|
b5e5fb5cd2 | ||
|
|
9a9152f7f8 | ||
|
|
d9ceb8ac78 | ||
|
|
d6726f976a | ||
|
|
bbfc724ceb | ||
|
|
7d3a6f9780 | ||
|
|
19fccde3a3 | ||
|
|
2cc2ac6efd | ||
|
|
d1ede223cf |
@@ -1,27 +0,0 @@
|
||||
## Description
|
||||
|
||||
<!-- What does this PR do? Why is it needed? -->
|
||||
|
||||
## Related Issues
|
||||
|
||||
<!-- Link related issues, e.g. "Fixes #123" -->
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Refactor (no functional change)
|
||||
- [ ] Documentation
|
||||
- [ ] Other (describe below)
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Code follows project conventions
|
||||
- [ ] `flutter analyze` and `flutter test` pass
|
||||
- [ ] `go vet ./...` and `go test ./...` pass in `go_backend/` (if Go code changed)
|
||||
- [ ] Documentation updated (if needed)
|
||||
- [ ] Commit messages follow the [conventional commits](https://www.conventionalcommits.org) format
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- For UI changes, add before/after screenshots. Delete this section otherwise. -->
|
||||
@@ -1,185 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect changed paths
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
dart: ${{ steps.filter.outputs.dart }}
|
||||
go: ${{ steps.filter.outputs.go }}
|
||||
android: ${{ steps.filter.outputs.android }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Filter paths
|
||||
uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
dart:
|
||||
- 'lib/**'
|
||||
- 'test/**'
|
||||
- 'assets/**'
|
||||
- 'pubspec.yaml'
|
||||
- 'pubspec.lock'
|
||||
- 'analysis_options.yaml'
|
||||
- 'l10n.yaml'
|
||||
- '.github/workflows/ci.yml'
|
||||
go:
|
||||
- 'go_backend/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
android:
|
||||
- 'android/**'
|
||||
- 'go_backend/**'
|
||||
- 'lib/models/settings.dart'
|
||||
- 'lib/providers/download_queue_provider*.dart'
|
||||
- 'lib/services/download_request_payload.dart'
|
||||
- 'lib/services/history_database.dart'
|
||||
- 'lib/services/platform_bridge.dart'
|
||||
- 'pubspec.yaml'
|
||||
- 'pubspec.lock'
|
||||
- '.github/workflows/ci.yml'
|
||||
|
||||
flutter:
|
||||
name: Flutter analyze & test
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.dart == 'true'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Cache pub dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
|
||||
restore-keys: pub-${{ runner.os }}-
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze
|
||||
|
||||
- name: Run tests
|
||||
run: flutter test
|
||||
|
||||
go:
|
||||
name: Go vet & test
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.go == 'true'
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: go_backend
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
# Keep in sync with release.yml
|
||||
go-version: "1.26.5"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
- name: Check formatting
|
||||
run: test -z "$(gofmt -l .)"
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
|
||||
android:
|
||||
name: Android compile & native tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.android == 'true'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "25"
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.5"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
- name: Install Android SDK & NDK
|
||||
run: |
|
||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
|
||||
"ndk;29.0.14206865" \
|
||||
"platforms;android-37" \
|
||||
"build-tools;37.0.0"
|
||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build Go backend for Android
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
go install golang.org/x/mobile/cmd/gomobile
|
||||
gomobile init
|
||||
mkdir -p ../android/app/libs
|
||||
gomobile bind \
|
||||
-target=android/arm,android/arm64 \
|
||||
-androidapi 24 \
|
||||
-o ../android/app/libs/gobackend.aar \
|
||||
.
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Configure Flutter SDK for Gradle
|
||||
run: echo "flutter.sdk=$FLUTTER_ROOT" > android/local.properties
|
||||
|
||||
- name: Compile Kotlin and run native unit tests
|
||||
run: ./android/gradlew -p android :app:compileDebugKotlin :app:testDebugUnitTest
|
||||
@@ -47,14 +47,15 @@ jobs:
|
||||
steps:
|
||||
- name: Free disk space
|
||||
run: |
|
||||
# Remove large unused tools (~15GB total). Docker prune was dropped:
|
||||
# it took 1-2 minutes and the rm below already frees enough.
|
||||
# Remove large unused tools (~15GB total)
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||
sudo rm -rf /usr/local/share/boost
|
||||
sudo rm -rf /usr/share/swift
|
||||
sudo rm -rf /usr/local/.ghcup
|
||||
# Clean docker images
|
||||
sudo docker image prune --all --force
|
||||
# Show available space
|
||||
df -h
|
||||
|
||||
@@ -65,12 +66,12 @@ jobs:
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "25"
|
||||
java-version: "17"
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.5"
|
||||
go-version: "1.25.8"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache Gradle for faster builds
|
||||
@@ -83,12 +84,6 @@ jobs:
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
- name: Cache Android NDK
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: /usr/local/lib/android/sdk/ndk/29.0.14206865
|
||||
key: ndk-29.0.14206865
|
||||
|
||||
- name: Install Android SDK & NDK
|
||||
run: |
|
||||
# Use pre-installed Android SDK on GitHub runners
|
||||
@@ -99,29 +94,22 @@ jobs:
|
||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
|
||||
|
||||
# Install NDK r29 (supports 16KB page size for Android 15+)
|
||||
# Keep the installed platform aligned with compileSdk/targetSdk.
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-37" "build-tools;37.0.0"
|
||||
# Platform android-36 and build-tools 36.0.0 for targetSdk 36 (Android 16)
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;29.0.14206865" "platforms;android-36" "build-tools;36.0.0"
|
||||
|
||||
# Set NDK path
|
||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> $GITHUB_ENV
|
||||
|
||||
- name: Install gomobile
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
# Installed from inside the module so the go.mod-pinned x/mobile
|
||||
# version is used (reproducible + covered by the setup-go cache).
|
||||
go install golang.org/x/mobile/cmd/gomobile
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
gomobile init
|
||||
|
||||
- name: Build Go backend for Android
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
mkdir -p ../android/app/libs
|
||||
# arm/arm64 only: ndk.abiFilters in app/build.gradle.kts strips
|
||||
# every other ABI from all outputs (universal APK included), so an
|
||||
# amd64 slice would never reach a shipped APK — it only bloats the
|
||||
# aar and slows this step.
|
||||
gomobile bind -target=android/arm,android/arm64 -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
|
||||
@@ -131,13 +119,6 @@ jobs:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Cache pub dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
|
||||
restore-keys: pub-${{ runner.os }}-
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
@@ -198,7 +179,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.5"
|
||||
go-version: "1.25.8"
|
||||
cache-dependency-path: go_backend/go.sum
|
||||
|
||||
# Cache CocoaPods
|
||||
@@ -210,10 +191,8 @@ jobs:
|
||||
restore-keys: pods-${{ runner.os }}-
|
||||
|
||||
- name: Install gomobile
|
||||
working-directory: go_backend
|
||||
run: |
|
||||
# go.mod-pinned x/mobile version (reproducible + cached by setup-go).
|
||||
go install golang.org/x/mobile/cmd/gomobile
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
gomobile init
|
||||
|
||||
- name: Build Go backend for iOS
|
||||
@@ -231,9 +210,8 @@ jobs:
|
||||
|
||||
- name: Add XCFramework to Xcode project
|
||||
run: |
|
||||
# xcodeproj usually ships with the preinstalled CocoaPods; only
|
||||
# install it when missing.
|
||||
gem list -i xcodeproj >/dev/null || sudo gem install xcodeproj
|
||||
# Install xcodeproj gem for modifying Xcode project
|
||||
sudo gem install xcodeproj
|
||||
|
||||
# Create Ruby script to add framework
|
||||
cat > add_framework.rb << 'EOF'
|
||||
@@ -276,13 +254,6 @@ jobs:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Cache pub dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: pub-${{ runner.os }}-${{ hashFiles('pubspec.lock') }}
|
||||
restore-keys: pub-${{ runner.os }}-
|
||||
|
||||
- name: Get Flutter dependencies
|
||||
run: flutter pub get
|
||||
|
||||
@@ -417,6 +388,8 @@ jobs:
|
||||
### Installation
|
||||
**Android**: Enable "Install from unknown sources" and install the APK
|
||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||
|
||||
  
|
||||
FOOTER
|
||||
|
||||
echo "Release body:"
|
||||
@@ -426,7 +399,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.get-version.outputs.version }}
|
||||
name: SpotiFLAC-Mobile ${{ needs.get-version.outputs.version }}
|
||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
||||
body_path: /tmp/release_body.txt
|
||||
files: ./release/*
|
||||
draft: false
|
||||
@@ -592,7 +565,7 @@ jobs:
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${ARM64_APK}" \
|
||||
-F caption="SpotiFLAC Mobile ${VERSION} - arm64 (recommended)"
|
||||
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
|
||||
fi
|
||||
|
||||
# Upload arm32 APK to channel
|
||||
@@ -601,7 +574,7 @@ jobs:
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${ARM32_APK}" \
|
||||
-F caption="SpotiFLAC Mobile ${VERSION} - arm32"
|
||||
-F caption="SpotiFLAC ${VERSION} - arm32"
|
||||
fi
|
||||
|
||||
# Upload iOS IPA to channel
|
||||
@@ -611,7 +584,7 @@ jobs:
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${IOS_IPA}" \
|
||||
-F caption="SpotiFLAC Mobile ${VERSION} - iOS (unsigned, sideload required)"
|
||||
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
|
||||
fi
|
||||
|
||||
echo "Telegram notification sent!"
|
||||
|
||||
+1
-5
@@ -60,9 +60,7 @@ ios/Flutter/Flutter.framework/
|
||||
ios/Flutter/Flutter.podspec
|
||||
|
||||
# Extension folder
|
||||
extension/*
|
||||
extension/v2/
|
||||
extension/v2/**
|
||||
extension/
|
||||
|
||||
# Agent instructions
|
||||
AGENTS.md
|
||||
@@ -84,8 +82,6 @@ flutter_*.log
|
||||
tool/
|
||||
.claude/settings.local.json
|
||||
.playwright-mcp/
|
||||
.rtk/
|
||||
CLAUDE.md
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
|
||||
+15
-22
@@ -49,10 +49,10 @@ Feature requests are welcome! Please use the feature request template and:
|
||||
|
||||
### Code Contributions
|
||||
|
||||
1. **Fork the repository** and create your branch from `main`
|
||||
1. **Fork the repository** and create your branch from `dev`
|
||||
2. **Make your changes** following our coding guidelines
|
||||
3. **Test your changes** thoroughly
|
||||
4. **Submit a pull request** to the `main` branch
|
||||
4. **Submit a pull request** to the `dev` branch
|
||||
|
||||
### Translations
|
||||
|
||||
@@ -83,7 +83,7 @@ Translation files are located in `lib/l10n/arb/`.
|
||||
|
||||
2. **Add upstream remote**
|
||||
```bash
|
||||
git remote add upstream https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
|
||||
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||
```
|
||||
|
||||
3. **Use FVM (Flutter Version: 3.41.5)**
|
||||
@@ -101,15 +101,11 @@ Translation files are located in `lib/l10n/arb/`.
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
6. **Set up Go environment (Go Version: 1.25.9)**
|
||||
|
||||
Building the Go backend for Android requires the **Android NDK** (r29 is what CI uses). Make sure `ANDROID_NDK_HOME` points to it and `CGO_ENABLED=1`.
|
||||
|
||||
6. **Set up Go environment (Go Version: 1.25.7)**
|
||||
```bash
|
||||
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
gomobile init
|
||||
cd go_backend
|
||||
cd go_backend
|
||||
mkdir -p ../android/app/libs
|
||||
gomobile init
|
||||
gomobile bind -target=android -androidapi 24 -o ../android/app/libs/gobackend.aar .
|
||||
cd ..
|
||||
```
|
||||
@@ -176,18 +172,17 @@ flutter analyze
|
||||
|
||||
### State Management
|
||||
|
||||
We use **Riverpod** for state management, with hand-written `Notifier`s
|
||||
(no `riverpod_annotation` code generation). Follow this pattern:
|
||||
We use **Riverpod** for state management. Follow these patterns:
|
||||
|
||||
```dart
|
||||
class MyNotifier extends Notifier<MyState> {
|
||||
// Use code generation with riverpod_annotation
|
||||
@riverpod
|
||||
class MyNotifier extends _$MyNotifier {
|
||||
@override
|
||||
MyState build() => MyState();
|
||||
|
||||
|
||||
// Methods to update state
|
||||
}
|
||||
|
||||
final myProvider = NotifierProvider<MyNotifier, MyState>(MyNotifier.new);
|
||||
```
|
||||
|
||||
### Localization
|
||||
@@ -243,7 +238,7 @@ chore(deps): update flutter_riverpod to 3.1.0
|
||||
1. **Update your fork**
|
||||
```bash
|
||||
git fetch upstream
|
||||
git rebase upstream/main
|
||||
git rebase upstream/dev
|
||||
```
|
||||
|
||||
2. **Create a feature branch**
|
||||
@@ -259,7 +254,7 @@ chore(deps): update flutter_riverpod to 3.1.0
|
||||
```
|
||||
|
||||
5. **Create a Pull Request**
|
||||
- Target the `main` branch
|
||||
- Target the `dev` branch
|
||||
- Fill in the PR template
|
||||
- Link related issues
|
||||
|
||||
@@ -277,13 +272,11 @@ chore(deps): update flutter_riverpod to 3.1.0
|
||||
- [ ] Commit messages follow guidelines
|
||||
- [ ] PR description is clear and complete
|
||||
|
||||
CI runs `flutter analyze`, `flutter test`, `go vet`, and `go test` on every pull request — make sure they pass locally before pushing.
|
||||
|
||||
## Questions?
|
||||
|
||||
If you have questions, feel free to:
|
||||
|
||||
- Open a [Discussion](https://github.com/spotiflacapp/SpotiFLAC-Mobile/discussions)
|
||||
- Check existing [Issues](https://github.com/spotiflacapp/SpotiFLAC-Mobile/issues)
|
||||
- Open a [Discussion](https://github.com/zarzet/SpotiFLAC-Mobile/discussions)
|
||||
- Check existing [Issues](https://github.com/zarzet/SpotiFLAC-Mobile/issues)
|
||||
|
||||
Thank you for contributing! 💚
|
||||
|
||||
@@ -52,7 +52,7 @@ Extensions let the community add new music sources and features without waiting
|
||||
### Developing Extensions
|
||||
|
||||
> [!NOTE]
|
||||
> Want to build your own extension? The [Extension Development Guide](https://spotiflac.zarz.moe/docs) has everything you need.
|
||||
> Want to build your own extension? The [Extension Development Guide](https://zarzet.github.io/SpotiFLAC-Mobile/docs) has everything you need.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ if (keystorePropertiesFile.exists()) {
|
||||
|
||||
android {
|
||||
namespace = "com.zarz.spotiflac"
|
||||
compileSdk = 37
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
buildFeatures {
|
||||
@@ -26,13 +26,13 @@ android {
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_25
|
||||
targetCompatibility = JavaVersion.VERSION_25
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "com.zarz.spotiflac"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = 37
|
||||
targetSdk = 36
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
multiDexEnabled = true
|
||||
@@ -62,8 +62,6 @@ android {
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
@@ -123,13 +121,8 @@ dependencies {
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0-beta02")
|
||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||
implementation("androidx.activity:activity-ktx:1.13.0")
|
||||
|
||||
// NativeDownloadFinalizer imports FFmpegKit APIs directly. The Flutter
|
||||
// plugin owns the runtime AAR; compileOnly avoids packaging it twice here.
|
||||
compileOnly("com.antonkarpenko:ffmpeg-kit-full:2.2.1")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0")
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
@@ -22,9 +21,6 @@
|
||||
android:label="SpotiFLAC Mobile"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="@xml/backup_rules_legacy"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:usesCleartextTraffic="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
@@ -104,12 +100,6 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="spotify-callback" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="session-grant" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Download Service -->
|
||||
@@ -118,23 +108,6 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name="com.ryanheise.audioservice.AudioService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true"
|
||||
android:enabled="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<receiver
|
||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- flutter_local_notifications receivers -->
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||
@@ -151,10 +124,6 @@
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
|
||||
<!-- FileProvider for APK installation -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -8,10 +8,6 @@ import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
@@ -42,10 +38,7 @@ class DownloadService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "download_channel"
|
||||
private const val ALERT_CHANNEL_ID = "download_alerts_v1"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val DOWNLOAD_RESULT_NOTIFICATION_ID = 1
|
||||
private const val VERIFICATION_REQUIRED_NOTIFICATION_ID = 4
|
||||
private const val WAKELOCK_TAG = "SpotiFLAC:DownloadWakeLock"
|
||||
private const val WAKELOCK_RENEW_MS = 30 * 60 * 1000L
|
||||
|
||||
@@ -160,15 +153,7 @@ class DownloadService : Service() {
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
// Header of the last written state snapshot (all fields except the
|
||||
// per-item payload). Lets steady-state polls skip re-reading and
|
||||
// re-parsing the full state file — which grows with every completed
|
||||
// item (results embed history rows and lyrics) — once the caller has
|
||||
// already consumed that items payload.
|
||||
@Volatile private var lastStateHeaderJson: String? = null
|
||||
@Volatile private var lastStateHeaderSerial = 0L
|
||||
|
||||
fun getNativeWorkerSnapshot(context: Context, sinceStateSerial: Long = 0L): String {
|
||||
fun getNativeWorkerSnapshot(context: Context): String {
|
||||
synchronized(NATIVE_WORKER_STATE_FILE_LOCK) {
|
||||
val stateFile = File(context.filesDir, NATIVE_WORKER_STATE_FILE)
|
||||
if (!stateFile.exists()) {
|
||||
@@ -180,35 +165,12 @@ class DownloadService : Service() {
|
||||
.put("completed", 0)
|
||||
.put("failed", 0)
|
||||
.put("skipped", 0)
|
||||
.put("state_serial", 0L)
|
||||
.put("items", JSONArray())
|
||||
.toString()
|
||||
}
|
||||
|
||||
val headerJson = lastStateHeaderJson
|
||||
val headerSerial = lastStateHeaderSerial
|
||||
val state: JSONObject
|
||||
if (sinceStateSerial > 0 &&
|
||||
headerSerial in 1..sinceStateSerial &&
|
||||
headerJson != null
|
||||
) {
|
||||
// Caller already consumed this items payload; serve the
|
||||
// cached compact header instead of re-parsing the file.
|
||||
state = JSONObject(headerJson)
|
||||
} else {
|
||||
state = AtomicFile(stateFile).openRead().bufferedReader(Charsets.UTF_8).use {
|
||||
it.readText()
|
||||
}.let { JSONObject(it) }
|
||||
val stateSerial = state.optLong("snapshot_serial", 0L)
|
||||
state.put("state_serial", stateSerial)
|
||||
if (sinceStateSerial > 0 && stateSerial in 1..sinceStateSerial) {
|
||||
state.remove("items")
|
||||
state.remove("item_ids")
|
||||
state.remove("last_result")
|
||||
state.remove("settings_json")
|
||||
}
|
||||
}
|
||||
|
||||
val state = AtomicFile(stateFile).openRead().bufferedReader(Charsets.UTF_8).use {
|
||||
it.readText()
|
||||
}.let { JSONObject(it) }
|
||||
val progressFile = File(context.filesDir, NATIVE_WORKER_PROGRESS_FILE)
|
||||
if (progressFile.exists()) {
|
||||
try {
|
||||
@@ -300,18 +262,7 @@ class DownloadService : Service() {
|
||||
private var latestCommittedStateSnapshotSerial = 0L
|
||||
private var latestCommittedProgressSnapshotSerial = 0L
|
||||
@Volatile private var nativeWorkerPaused = false
|
||||
@Volatile private var nativeWorkerNetworkPaused = false
|
||||
@Volatile private var nativeWorkerVerificationPaused = false
|
||||
@Volatile private var nativeWorkerCancelRequested = false
|
||||
private var nativeWorkerDownloadNetworkMode = "any"
|
||||
private var nativeWorkerNetworkCallback: ConnectivityManager.NetworkCallback? = null
|
||||
private val nativeWorkerWifiNetworks = mutableSetOf<Network>()
|
||||
// Bumped every time a new native queue replaces the current one. A worker
|
||||
// coroutine that observes a different generation than its own must stop
|
||||
// without touching the snapshot or the service lifecycle: cancel() alone
|
||||
// cannot interrupt the blocking gomobile call it may be sitting in, and
|
||||
// the shared pause/cancel flags get reset for the new run.
|
||||
@Volatile private var nativeWorkerGeneration = 0L
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -362,7 +313,26 @@ class DownloadService : Service() {
|
||||
}
|
||||
ACTION_PAUSE_NATIVE_QUEUE -> {
|
||||
nativeWorkerPaused = true
|
||||
cancelActiveNativeItemForPause()
|
||||
var itemIdToCancel = ""
|
||||
synchronized(nativeWorkerItems) {
|
||||
val activeItem = nativeWorkerItems.firstOrNull {
|
||||
it.status == "downloading" || it.status == "finalizing"
|
||||
} ?: nativeWorkerItems.firstOrNull {
|
||||
it.itemId == nativeWorkerCurrentItemId && it.status == "queued"
|
||||
}
|
||||
activeItem?.let {
|
||||
it.status = "queued"
|
||||
itemIdToCancel = it.itemId
|
||||
}
|
||||
}
|
||||
if (itemIdToCancel.isBlank()) itemIdToCancel = nativeWorkerCurrentItemId
|
||||
if (itemIdToCancel.isNotBlank()) {
|
||||
try {
|
||||
Gobackend.cancelDownload(itemIdToCancel)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
NativeDownloadFinalizer.cancelActiveWork()
|
||||
writeNativeWorkerSnapshotAsync(
|
||||
isRunning = nativeWorkerJob?.isActive == true,
|
||||
isPaused = true,
|
||||
@@ -373,19 +343,16 @@ class DownloadService : Service() {
|
||||
}
|
||||
ACTION_RESUME_NATIVE_QUEUE -> {
|
||||
nativeWorkerPaused = false
|
||||
val stillPaused = isNativeWorkerPaused()
|
||||
writeNativeWorkerSnapshotAsync(
|
||||
isRunning = nativeWorkerJob?.isActive == true,
|
||||
isPaused = stillPaused,
|
||||
isPaused = false,
|
||||
currentItemId = "",
|
||||
message = if (stillPaused) nativeWorkerPauseMessage() else "Resumed",
|
||||
message = "Resumed",
|
||||
includeItems = true
|
||||
)
|
||||
}
|
||||
ACTION_CANCEL_NATIVE_QUEUE -> {
|
||||
nativeWorkerCancelRequested = true
|
||||
nativeWorkerVerificationPaused = false
|
||||
cancelNativeVerificationNotification()
|
||||
synchronized(nativeWorkerItems) {
|
||||
for (item in nativeWorkerItems) {
|
||||
if (item.status == "queued" ||
|
||||
@@ -470,7 +437,7 @@ class DownloadService : Service() {
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val progressChannel = NotificationChannel(
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Download Service",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
@@ -478,17 +445,8 @@ class DownloadService : Service() {
|
||||
description = "Shows download progress"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val alertChannel = NotificationChannel(
|
||||
ALERT_CHANNEL_ID,
|
||||
"Download Alerts",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = "Important download status and actions that need attention"
|
||||
enableVibration(true)
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.createNotificationChannel(progressChannel)
|
||||
manager.createNotificationChannel(alertChannel)
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,51 +469,24 @@ class DownloadService : Service() {
|
||||
|
||||
private fun startNativeWorker(requestsJson: String, settingsJson: String) {
|
||||
flushNativeAlbumReplayGainJournalIfComplete()
|
||||
val requestedRunId = parseNativeWorkerRunId(settingsJson)
|
||||
nativeWorkerRunId = parseNativeWorkerRunId(settingsJson)
|
||||
val requests = try {
|
||||
parseNativeDownloadRequests(requestsJson)
|
||||
} catch (e: Exception) {
|
||||
if (nativeWorkerJob?.isActive != true) {
|
||||
nativeWorkerRunId = requestedRunId
|
||||
writeNativeWorkerSnapshot(
|
||||
isRunning = false,
|
||||
isPaused = false,
|
||||
currentItemId = "",
|
||||
message = "Invalid native queue payload: ${e.message}",
|
||||
settingsJson = settingsJson,
|
||||
includeItems = true
|
||||
)
|
||||
stopForegroundService(cancelNativeWorker = false)
|
||||
}
|
||||
writeNativeWorkerSnapshot(
|
||||
isRunning = false,
|
||||
isPaused = false,
|
||||
currentItemId = "",
|
||||
message = "Invalid native queue payload: ${e.message}",
|
||||
settingsJson = settingsJson,
|
||||
includeItems = true
|
||||
)
|
||||
stopForegroundService(cancelNativeWorker = false)
|
||||
return
|
||||
}
|
||||
nativeWorkerRunId = requestedRunId
|
||||
cancelNativeVerificationNotification()
|
||||
// Abort the previous run's in-flight work before the shared flags are
|
||||
// reset for the new run: the coroutine cancel below cannot interrupt a
|
||||
// blocking gomobile download by itself.
|
||||
synchronized(nativeWorkerItems) {
|
||||
for (item in nativeWorkerItems) {
|
||||
if (item.status == "preparing" ||
|
||||
item.status == "downloading" ||
|
||||
item.status == "finalizing"
|
||||
) {
|
||||
try {
|
||||
Gobackend.cancelDownload(item.itemId)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NativeDownloadFinalizer.cancelActiveWork()
|
||||
nativeWorkerGeneration++
|
||||
val generation = nativeWorkerGeneration
|
||||
nativeWorkerJob?.cancel(CancellationException("Native queue replaced"))
|
||||
nativeWorkerPaused = false
|
||||
nativeWorkerNetworkPaused = false
|
||||
nativeWorkerVerificationPaused = false
|
||||
nativeWorkerCancelRequested = false
|
||||
unregisterNativeWorkerNetworkCallback()
|
||||
queueCount = requests.size
|
||||
synchronized(nativeReplayGainEntries) {
|
||||
nativeReplayGainEntries.clear()
|
||||
@@ -588,7 +519,6 @@ class DownloadService : Service() {
|
||||
}
|
||||
)
|
||||
}
|
||||
configureNativeWorkerNetworkPolicy(settingsJson)
|
||||
writeNativeReplayGainJournal()
|
||||
currentStatus = "preparing"
|
||||
currentTrackName = requests.firstOrNull()?.trackName ?: ""
|
||||
@@ -598,15 +528,15 @@ class DownloadService : Service() {
|
||||
startForegroundService()
|
||||
writeNativeWorkerSnapshot(
|
||||
isRunning = true,
|
||||
isPaused = isNativeWorkerPaused(),
|
||||
isPaused = false,
|
||||
currentItemId = "",
|
||||
message = if (isNativeWorkerPaused()) nativeWorkerPauseMessage() else "Starting",
|
||||
message = "Starting",
|
||||
settingsJson = settingsJson,
|
||||
includeItems = true
|
||||
)
|
||||
|
||||
nativeWorkerJob = serviceScope.launch {
|
||||
runNativeWorker(requests, settingsJson, generation)
|
||||
runNativeWorker(requests, settingsJson)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,183 +548,6 @@ class DownloadService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun isNativeWorkerPaused(): Boolean =
|
||||
nativeWorkerPaused ||
|
||||
nativeWorkerNetworkPaused ||
|
||||
nativeWorkerVerificationPaused
|
||||
|
||||
private fun nativeWorkerPauseMessage(): String = when {
|
||||
nativeWorkerVerificationPaused -> "Verification required"
|
||||
nativeWorkerNetworkPaused -> "Waiting for Wi-Fi"
|
||||
else -> "Paused"
|
||||
}
|
||||
|
||||
private fun cancelActiveNativeItemForPause() {
|
||||
var itemIdToCancel = ""
|
||||
synchronized(nativeWorkerItems) {
|
||||
val activeItem = nativeWorkerItems.firstOrNull {
|
||||
it.status == "downloading" || it.status == "finalizing"
|
||||
} ?: nativeWorkerItems.firstOrNull {
|
||||
it.itemId == nativeWorkerCurrentItemId && it.status == "queued"
|
||||
}
|
||||
activeItem?.let {
|
||||
it.status = "queued"
|
||||
it.progress = 0.0
|
||||
it.bytesReceived = 0L
|
||||
it.bytesTotal = 0L
|
||||
itemIdToCancel = it.itemId
|
||||
}
|
||||
}
|
||||
if (itemIdToCancel.isBlank()) itemIdToCancel = nativeWorkerCurrentItemId
|
||||
if (itemIdToCancel.isNotBlank()) {
|
||||
try {
|
||||
Gobackend.cancelDownload(itemIdToCancel)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
NativeDownloadFinalizer.cancelActiveWork()
|
||||
}
|
||||
|
||||
private fun configureNativeWorkerNetworkPolicy(settingsJson: String) {
|
||||
nativeWorkerDownloadNetworkMode = try {
|
||||
JSONObject(settingsJson).optString("download_network_mode", "any")
|
||||
} catch (_: Exception) {
|
||||
"any"
|
||||
}
|
||||
|
||||
unregisterNativeWorkerNetworkCallback()
|
||||
if (!NativeWorkerPolicy.requiresWifi(nativeWorkerDownloadNetworkMode)) {
|
||||
nativeWorkerNetworkPaused = false
|
||||
return
|
||||
}
|
||||
|
||||
nativeWorkerNetworkPaused = !hasUsableWifiConnection()
|
||||
val connectivityManager =
|
||||
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
synchronized(nativeWorkerWifiNetworks) {
|
||||
nativeWorkerWifiNetworks.add(network)
|
||||
}
|
||||
refreshNativeWorkerNetworkPause()
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
synchronized(nativeWorkerWifiNetworks) {
|
||||
nativeWorkerWifiNetworks.remove(network)
|
||||
}
|
||||
refreshNativeWorkerNetworkPause()
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities,
|
||||
) {
|
||||
synchronized(nativeWorkerWifiNetworks) {
|
||||
if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||
networkCapabilities.hasCapability(
|
||||
NetworkCapabilities.NET_CAPABILITY_INTERNET,
|
||||
)
|
||||
) {
|
||||
nativeWorkerWifiNetworks.add(network)
|
||||
} else {
|
||||
nativeWorkerWifiNetworks.remove(network)
|
||||
}
|
||||
}
|
||||
refreshNativeWorkerNetworkPause()
|
||||
}
|
||||
}
|
||||
try {
|
||||
val request = NetworkRequest.Builder()
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
connectivityManager.registerNetworkCallback(request, callback)
|
||||
nativeWorkerNetworkCallback = callback
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"DownloadService",
|
||||
"Failed to monitor Wi-Fi for native worker: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasUsableWifiConnection(): Boolean {
|
||||
val connectivityManager =
|
||||
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
if (synchronized(nativeWorkerWifiNetworks) {
|
||||
nativeWorkerWifiNetworks.isNotEmpty()
|
||||
}
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return try {
|
||||
val activeNetwork = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities =
|
||||
connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshNativeWorkerNetworkPause() {
|
||||
if (!NativeWorkerPolicy.requiresWifi(nativeWorkerDownloadNetworkMode)) return
|
||||
|
||||
val shouldPause = NativeWorkerPolicy.shouldPauseForNetwork(
|
||||
nativeWorkerDownloadNetworkMode,
|
||||
hasUsableWifiConnection(),
|
||||
)
|
||||
if (shouldPause == nativeWorkerNetworkPaused) return
|
||||
|
||||
nativeWorkerNetworkPaused = shouldPause
|
||||
if (nativeWorkerJob?.isActive != true) return
|
||||
|
||||
if (shouldPause) {
|
||||
currentStatus = if (nativeWorkerVerificationPaused) {
|
||||
"verification_required"
|
||||
} else {
|
||||
"waiting_wifi"
|
||||
}
|
||||
cancelActiveNativeItemForPause()
|
||||
updateNotification(0L, 0L)
|
||||
} else {
|
||||
currentStatus = if (nativeWorkerVerificationPaused) {
|
||||
"verification_required"
|
||||
} else {
|
||||
"preparing"
|
||||
}
|
||||
updateNotification(0L, 0L)
|
||||
}
|
||||
writeNativeWorkerSnapshotAsync(
|
||||
isRunning = nativeWorkerJob?.isActive == true,
|
||||
isPaused = isNativeWorkerPaused(),
|
||||
currentItemId = "",
|
||||
message = if (isNativeWorkerPaused()) {
|
||||
nativeWorkerPauseMessage()
|
||||
} else {
|
||||
"Wi-Fi restored"
|
||||
},
|
||||
includeItems = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun unregisterNativeWorkerNetworkCallback() {
|
||||
val callback = nativeWorkerNetworkCallback
|
||||
nativeWorkerNetworkCallback = null
|
||||
synchronized(nativeWorkerWifiNetworks) {
|
||||
nativeWorkerWifiNetworks.clear()
|
||||
}
|
||||
if (callback == null) return
|
||||
try {
|
||||
val connectivityManager =
|
||||
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
connectivityManager.unregisterNetworkCallback(callback)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseNativeDownloadRequests(requestsJson: String): List<NativeDownloadRequest> {
|
||||
val array = JSONArray(requestsJson)
|
||||
val requests = ArrayList<NativeDownloadRequest>(array.length())
|
||||
@@ -849,31 +602,25 @@ class DownloadService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runNativeWorker(
|
||||
requests: List<NativeDownloadRequest>,
|
||||
settingsJson: String,
|
||||
generation: Long
|
||||
) {
|
||||
val rateLimitAttempts = mutableMapOf<String, Int>()
|
||||
private suspend fun runNativeWorker(requests: List<NativeDownloadRequest>, settingsJson: String) {
|
||||
var completed = 0
|
||||
var failed = 0
|
||||
try {
|
||||
var requestIndex = 0
|
||||
while (requestIndex < requests.size) {
|
||||
val request = requests[requestIndex]
|
||||
while (isNativeWorkerPaused() &&
|
||||
!nativeWorkerCancelRequested &&
|
||||
generation == nativeWorkerGeneration
|
||||
) {
|
||||
while (nativeWorkerPaused && !nativeWorkerCancelRequested) {
|
||||
writeNativeWorkerSnapshot(
|
||||
isRunning = true,
|
||||
isPaused = true,
|
||||
currentItemId = request.itemId,
|
||||
message = nativeWorkerPauseMessage(),
|
||||
message = "Paused",
|
||||
settingsJson = settingsJson,
|
||||
includeItems = true
|
||||
)
|
||||
delay(500)
|
||||
}
|
||||
if (nativeWorkerCancelRequested || generation != nativeWorkerGeneration) {
|
||||
if (nativeWorkerCancelRequested) {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -906,29 +653,15 @@ class DownloadService : Service() {
|
||||
try {
|
||||
Gobackend.initItemProgress(request.itemId)
|
||||
progressJob = serviceScope.launch {
|
||||
// The snapshot write is an AtomicFile open+fsync+
|
||||
// rename; skip ticks where progress hasn't moved.
|
||||
var lastSignature: String? = null
|
||||
while (true) {
|
||||
updateNativeWorkerItemProgress(request.itemId)
|
||||
val signature = synchronized(nativeWorkerItems) {
|
||||
nativeWorkerItems
|
||||
.firstOrNull { it.itemId == request.itemId }
|
||||
?.let {
|
||||
"${it.status}:${it.bytesReceived}:" +
|
||||
"${it.bytesTotal}:${it.progress}"
|
||||
}
|
||||
}
|
||||
if (signature != lastSignature) {
|
||||
lastSignature = signature
|
||||
writeNativeWorkerSnapshot(
|
||||
isRunning = true,
|
||||
isPaused = false,
|
||||
currentItemId = request.itemId,
|
||||
message = "Downloading",
|
||||
settingsJson = settingsJson
|
||||
)
|
||||
}
|
||||
writeNativeWorkerSnapshot(
|
||||
isRunning = true,
|
||||
isPaused = false,
|
||||
currentItemId = request.itemId,
|
||||
message = "Downloading",
|
||||
settingsJson = settingsJson
|
||||
)
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
@@ -937,11 +670,6 @@ class DownloadService : Service() {
|
||||
}
|
||||
progressJob.cancel()
|
||||
progressJob = null
|
||||
if (generation != nativeWorkerGeneration) {
|
||||
// Superseded while blocked in the download call; the
|
||||
// new run owns the shared state now.
|
||||
break
|
||||
}
|
||||
var result = JSONObject(response)
|
||||
if (result.optBoolean("success", false)) {
|
||||
currentStatus = "finalizing"
|
||||
@@ -966,8 +694,8 @@ class DownloadService : Service() {
|
||||
settingsJson
|
||||
) {
|
||||
nativeWorkerCancelRequested ||
|
||||
isNativeWorkerPaused() ||
|
||||
generation != nativeWorkerGeneration
|
||||
nativeWorkerPaused ||
|
||||
nativeWorkerJob?.isActive == false
|
||||
}
|
||||
}
|
||||
if (result.optBoolean("success", false)) {
|
||||
@@ -976,6 +704,7 @@ class DownloadService : Service() {
|
||||
nativeReplayGainEntries.add(JSONObject(replayGain.toString()))
|
||||
}
|
||||
}
|
||||
completed++
|
||||
updateNativeWorkerItem(request.itemId) {
|
||||
it.status = "completed"
|
||||
it.progress = 1.0
|
||||
@@ -986,32 +715,7 @@ class DownloadService : Service() {
|
||||
writeNativeAlbumReplayGainIfComplete()
|
||||
} else {
|
||||
val errorType = result.optString("error_type")
|
||||
val errorMessage = result.optString("error")
|
||||
if (errorType == "cancelled" &&
|
||||
!isNativeWorkerPaused() &&
|
||||
!nativeWorkerCancelRequested &&
|
||||
generation == nativeWorkerGeneration
|
||||
) {
|
||||
// A pause from Dart cancels the in-flight Go
|
||||
// download directly but delivers the pause flag
|
||||
// via a startService intent through the main
|
||||
// looper; the download can unwind first. Give the
|
||||
// flag a moment to settle before classifying this
|
||||
// cancellation as a permanent skip.
|
||||
var waitedMs = 0L
|
||||
while (waitedMs < 1500 &&
|
||||
!isNativeWorkerPaused() &&
|
||||
!nativeWorkerCancelRequested &&
|
||||
generation == nativeWorkerGeneration
|
||||
) {
|
||||
delay(100)
|
||||
waitedMs += 100
|
||||
}
|
||||
}
|
||||
if (errorType == "cancelled" &&
|
||||
isNativeWorkerPaused() &&
|
||||
!nativeWorkerCancelRequested
|
||||
) {
|
||||
if (errorType == "cancelled" && nativeWorkerPaused && !nativeWorkerCancelRequested) {
|
||||
updateNativeWorkerItem(request.itemId) {
|
||||
it.status = "queued"
|
||||
it.progress = 0.0
|
||||
@@ -1029,73 +733,15 @@ class DownloadService : Service() {
|
||||
includeItems = true
|
||||
)
|
||||
retryCurrentRequest = true
|
||||
} else if (NativeWorkerPolicy.shouldRetryRateLimit(
|
||||
errorType = errorType,
|
||||
errorMessage = errorMessage,
|
||||
attempts = rateLimitAttempts[request.itemId] ?: 0,
|
||||
)
|
||||
) {
|
||||
rateLimitAttempts[request.itemId] =
|
||||
(rateLimitAttempts[request.itemId] ?: 0) + 1
|
||||
val delaySeconds = NativeWorkerPolicy.rateLimitDelaySeconds(
|
||||
retryAfterSeconds = result
|
||||
.optInt("retry_after_seconds", 0)
|
||||
.takeIf { it > 0 },
|
||||
errorMessage = errorMessage,
|
||||
)
|
||||
currentStatus = "rate_limited"
|
||||
updateNativeWorkerItem(request.itemId) {
|
||||
it.status = "queued"
|
||||
it.progress = 0.0
|
||||
it.bytesReceived = 0L
|
||||
it.bytesTotal = 0L
|
||||
it.error = "Rate limited, retrying in ${delaySeconds}s"
|
||||
it.resultJson = null
|
||||
}
|
||||
writeNativeWorkerSnapshot(
|
||||
isRunning = true,
|
||||
isPaused = isNativeWorkerPaused(),
|
||||
currentItemId = request.itemId,
|
||||
message = "Rate limited, retrying in ${delaySeconds}s",
|
||||
settingsJson = settingsJson,
|
||||
includeItems = true,
|
||||
)
|
||||
updateNotification(0L, 0L)
|
||||
delay(delaySeconds * 1000L)
|
||||
retryCurrentRequest = true
|
||||
} else if (NativeWorkerPolicy.isVerificationRequired(
|
||||
errorType = errorType,
|
||||
errorMessage = errorMessage,
|
||||
)
|
||||
) {
|
||||
nativeWorkerVerificationPaused = true
|
||||
currentStatus = "verification_required"
|
||||
updateNativeWorkerItem(request.itemId) {
|
||||
it.status = "failed"
|
||||
it.error = errorMessage
|
||||
it.resultJson = result
|
||||
}
|
||||
writeNativeReplayGainJournal()
|
||||
writeNativeWorkerSnapshot(
|
||||
isRunning = true,
|
||||
isPaused = true,
|
||||
currentItemId = request.itemId,
|
||||
message = "Verification required",
|
||||
lastResult = result,
|
||||
settingsJson = settingsJson,
|
||||
includeItems = true,
|
||||
)
|
||||
scheduleNativeVerificationNotification(generation)
|
||||
updateNotification(0L, 0L)
|
||||
retryCurrentRequest = true
|
||||
} else {
|
||||
failed++
|
||||
updateNativeWorkerItem(request.itemId) {
|
||||
it.status = if (errorType == "cancelled") {
|
||||
"skipped"
|
||||
} else {
|
||||
"failed"
|
||||
}
|
||||
it.error = errorMessage
|
||||
it.error = result.optString("error")
|
||||
it.resultJson = result
|
||||
}
|
||||
writeNativeReplayGainJournal()
|
||||
@@ -1121,6 +767,7 @@ class DownloadService : Service() {
|
||||
}
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
failed++
|
||||
updateNativeWorkerItem(request.itemId) {
|
||||
it.status = "failed"
|
||||
it.error = e.message ?: "Native download failed"
|
||||
@@ -1150,31 +797,19 @@ class DownloadService : Service() {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (generation == nativeWorkerGeneration) {
|
||||
if (!nativeWorkerCancelRequested) {
|
||||
flushNativeAlbumReplayGainJournalIfComplete()
|
||||
}
|
||||
val counts = nativeWorkerCounts()
|
||||
val shouldNotifyCompletion =
|
||||
NativeWorkerPolicy.shouldNotifyQueueComplete(
|
||||
cancelRequested = nativeWorkerCancelRequested,
|
||||
completed = counts.completed,
|
||||
failed = counts.failed,
|
||||
)
|
||||
currentStatus = "finalizing"
|
||||
writeNativeWorkerSnapshot(
|
||||
isRunning = false,
|
||||
isPaused = false,
|
||||
currentItemId = "",
|
||||
message = if (nativeWorkerCancelRequested) "Cancelled" else "Finished",
|
||||
settingsJson = settingsJson,
|
||||
includeItems = true
|
||||
)
|
||||
stopForegroundService(cancelNativeWorker = false)
|
||||
if (shouldNotifyCompletion) {
|
||||
showNativeQueueComplete(counts)
|
||||
}
|
||||
if (!nativeWorkerCancelRequested) {
|
||||
flushNativeAlbumReplayGainJournalIfComplete()
|
||||
}
|
||||
currentStatus = "finalizing"
|
||||
writeNativeWorkerSnapshot(
|
||||
isRunning = false,
|
||||
isPaused = false,
|
||||
currentItemId = "",
|
||||
message = if (nativeWorkerCancelRequested) "Cancelled" else "Finished",
|
||||
settingsJson = settingsJson,
|
||||
includeItems = true
|
||||
)
|
||||
stopForegroundService(cancelNativeWorker = false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1430,13 +1065,8 @@ class DownloadService : Service() {
|
||||
.put("message", message)
|
||||
.put("updated_at", System.currentTimeMillis())
|
||||
.put("snapshot_serial", snapshotSerial)
|
||||
.put("state_serial", if (includeItems) snapshotSerial else latestCommittedStateSnapshotSerial)
|
||||
.put("item_ids", nativeWorkerItemIds())
|
||||
.put("snapshot_mode", if (includeItems) "compact_items" else "delta")
|
||||
// Snapshot of the header before the per-item payload is
|
||||
// attached; served to pollers that already consumed this
|
||||
// items payload (see getNativeWorkerSnapshot).
|
||||
val headerCandidate = if (includeItems) snapshot.toString() else null
|
||||
snapshot.put("item_ids", nativeWorkerItemIds())
|
||||
if (includeItems) {
|
||||
snapshot.put("items", nativeWorkerItemsSnapshot(includeStatic = false))
|
||||
} else {
|
||||
@@ -1466,10 +1096,6 @@ class DownloadService : Service() {
|
||||
stream = null
|
||||
if (includeItems) {
|
||||
latestCommittedStateSnapshotSerial = snapshotSerial
|
||||
if (headerCandidate != null) {
|
||||
lastStateHeaderJson = headerCandidate
|
||||
lastStateHeaderSerial = snapshotSerial
|
||||
}
|
||||
} else {
|
||||
latestCommittedProgressSnapshotSerial = snapshotSerial
|
||||
}
|
||||
@@ -1701,9 +1327,6 @@ class DownloadService : Service() {
|
||||
NativeDownloadFinalizer.cancelActiveWork()
|
||||
nativeWorkerJob?.cancel(CancellationException("Download service stopped"))
|
||||
nativeWorkerPaused = false
|
||||
nativeWorkerNetworkPaused = false
|
||||
nativeWorkerVerificationPaused = false
|
||||
cancelNativeVerificationNotification()
|
||||
}
|
||||
if (cancelNativeWorker && hasNativeWorkerState()) {
|
||||
writeNativeWorkerSnapshot(
|
||||
@@ -1714,9 +1337,6 @@ class DownloadService : Service() {
|
||||
includeItems = true
|
||||
)
|
||||
}
|
||||
unregisterNativeWorkerNetworkCallback()
|
||||
nativeWorkerDownloadNetworkMode = "any"
|
||||
nativeWorkerNetworkPaused = false
|
||||
nativeWorkerJob = null
|
||||
isRunning = false
|
||||
releaseWakeLock()
|
||||
@@ -1756,13 +1376,7 @@ class DownloadService : Service() {
|
||||
"Downloading..."
|
||||
}
|
||||
|
||||
val text = if (currentStatus == "verification_required") {
|
||||
"Open the app to complete verification"
|
||||
} else if (currentStatus == "rate_limited") {
|
||||
"Rate limited, retrying shortly..."
|
||||
} else if (currentStatus == "waiting_wifi") {
|
||||
"Waiting for Wi-Fi..."
|
||||
} else if (currentStatus == "finalizing") {
|
||||
val text = if (currentStatus == "finalizing") {
|
||||
if (currentArtistName.isNotEmpty()) currentArtistName else "Embedding metadata..."
|
||||
} else if (currentStatus == "preparing" && total <= 0) {
|
||||
"Preparing download..."
|
||||
@@ -1800,102 +1414,8 @@ class DownloadService : Service() {
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun showNativeQueueComplete(counts: NativeWorkerCounts) {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val title = if (counts.failed > 0) {
|
||||
"Downloads Finished (${counts.completed} done, ${counts.failed} failed)"
|
||||
} else {
|
||||
"All Downloads Complete"
|
||||
}
|
||||
val body = if (counts.failed > 0) {
|
||||
"${counts.completed} downloaded, ${counts.failed} failed"
|
||||
} else {
|
||||
"${counts.completed} tracks downloaded successfully"
|
||||
}
|
||||
val builder = NotificationCompat.Builder(this, ALERT_CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
builder.setDefaults(Notification.DEFAULT_SOUND or Notification.DEFAULT_VIBRATE)
|
||||
}
|
||||
|
||||
try {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.notify(DOWNLOAD_RESULT_NOTIFICATION_ID, builder.build())
|
||||
} catch (e: SecurityException) {
|
||||
android.util.Log.w(
|
||||
"DownloadService",
|
||||
"Completion notification permission denied: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleNativeVerificationNotification(generation: Long) {
|
||||
serviceScope.launch {
|
||||
// Give an active Flutter poller time to take ownership of the
|
||||
// verification flow. If Flutter is suspended, the service remains
|
||||
// paused and publishes the alert itself.
|
||||
delay(2_000L)
|
||||
if (generation == nativeWorkerGeneration &&
|
||||
nativeWorkerVerificationPaused &&
|
||||
!nativeWorkerCancelRequested
|
||||
) {
|
||||
showNativeVerificationRequired()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showNativeVerificationRequired() {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val builder = NotificationCompat.Builder(this, ALERT_CHANNEL_ID)
|
||||
.setContentTitle("Verification required")
|
||||
.setContentText("Open the app to complete verification and resume downloads")
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
builder.setDefaults(Notification.DEFAULT_SOUND or Notification.DEFAULT_VIBRATE)
|
||||
}
|
||||
|
||||
try {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.notify(VERIFICATION_REQUIRED_NOTIFICATION_ID, builder.build())
|
||||
} catch (e: SecurityException) {
|
||||
android.util.Log.w(
|
||||
"DownloadService",
|
||||
"Verification notification permission denied: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelNativeVerificationNotification() {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager.cancel(VERIFICATION_REQUIRED_NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
unregisterNativeWorkerNetworkCallback()
|
||||
nativeWorkerCancelRequested = true
|
||||
NativeDownloadFinalizer.cancelActiveWork()
|
||||
nativeWorkerJob?.cancel(CancellationException("Download service destroyed"))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,6 @@ import com.antonkarpenko.ffmpegkit.FFmpegSession
|
||||
import com.antonkarpenko.ffmpegkit.FFmpegSessionCompleteCallback
|
||||
import com.antonkarpenko.ffmpegkit.LogRedirectionStrategy
|
||||
import com.antonkarpenko.ffmpegkit.ReturnCode
|
||||
import com.zarz.spotiflac.SafDownloadHandler.mimeTypeForExt
|
||||
import com.zarz.spotiflac.SafDownloadHandler.normalizeExt
|
||||
import gobackend.Gobackend
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
@@ -26,14 +24,13 @@ import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
object NativeDownloadFinalizer {
|
||||
private const val TAG = "NativeFinalizer"
|
||||
const val NATIVE_WORKER_CONTRACT_VERSION = 1
|
||||
// Native finalizer owns background-safe history writes while Flutter may be suspended.
|
||||
// Keep this schema contract in sync with Dart HistoryDatabase before bumping either side.
|
||||
const val HISTORY_SCHEMA_VERSION = 10
|
||||
private const val HISTORY_SCHEMA_VERSION = 9
|
||||
private val activeFFmpegSessionIds = mutableSetOf<Long>()
|
||||
private val nativeFFmpegSessionIds = mutableSetOf<Long>()
|
||||
private val activeFFmpegSessionLock = Any()
|
||||
@@ -85,8 +82,6 @@ object NativeDownloadFinalizer {
|
||||
"spotify_id_norm",
|
||||
"isrc_norm",
|
||||
"match_key",
|
||||
"album_key",
|
||||
"search_text",
|
||||
)
|
||||
private val androidStoragePathAliases = listOf(
|
||||
"/storage/emulated/0",
|
||||
@@ -190,10 +185,6 @@ object NativeDownloadFinalizer {
|
||||
),
|
||||
)
|
||||
|
||||
// Once the output has been published to its final destination the audio
|
||||
// is complete; failures past that point are bookkeeping and must never
|
||||
// trigger the destructive cleanup below.
|
||||
var outputPublished = result.optBoolean("already_exists", false)
|
||||
try {
|
||||
var qualityMetadataRefreshed = false
|
||||
if (!result.optBoolean("already_exists", false)) {
|
||||
@@ -207,66 +198,30 @@ object NativeDownloadFinalizer {
|
||||
checkCancelled(shouldCancel)
|
||||
finalizeMetadata(context, effectiveInput, state)
|
||||
checkCancelled(shouldCancel)
|
||||
writeExternalLrc(context, effectiveInput, state)
|
||||
checkCancelled(shouldCancel)
|
||||
runPostProcessing(context, effectiveInput, state, shouldCancel)
|
||||
checkCancelled(shouldCancel)
|
||||
val replayGain = writeReplayGain(context, effectiveInput, state, shouldCancel)
|
||||
if (replayGain != null) result.put("replaygain", replayGain)
|
||||
checkCancelled(shouldCancel)
|
||||
try {
|
||||
refreshFinalAudioQualityMetadata(context, result, state)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Quality metadata refresh failed (non-fatal): ${e.message}")
|
||||
}
|
||||
qualityMetadataRefreshed = true
|
||||
try {
|
||||
finalizeQualityVariantFilename(context, effectiveInput, state)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Quality variant rename failed (non-fatal): ${e.message}")
|
||||
}
|
||||
checkCancelled(shouldCancel)
|
||||
try {
|
||||
writeExternalLrc(context, effectiveInput, state)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "External LRC write failed (non-fatal): ${e.message}")
|
||||
}
|
||||
checkCancelled(shouldCancel)
|
||||
if (isDeferredSafPublish(effectiveInput)) {
|
||||
refreshFinalAudioQualityMetadata(context, result, state)
|
||||
qualityMetadataRefreshed = true
|
||||
publishDeferredSafOutput(context, effectiveInput, state)
|
||||
} else {
|
||||
promoteStagedSafOutputIfNeeded(context, effectiveInput, state)
|
||||
}
|
||||
outputPublished = true
|
||||
}
|
||||
checkCancelled(shouldCancel)
|
||||
if (!qualityMetadataRefreshed) {
|
||||
try {
|
||||
refreshFinalAudioQualityMetadata(context, result, state)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(TAG, "Quality metadata refresh failed (non-fatal): ${e.message}")
|
||||
}
|
||||
refreshFinalAudioQualityMetadata(context, result, state)
|
||||
}
|
||||
|
||||
val saveDownloadHistory = parseObject(settingsJson)
|
||||
.optBoolean("save_download_history", true)
|
||||
val preserveQualityVariant = input.request
|
||||
.optBoolean("allow_quality_variant", false)
|
||||
val history = if (
|
||||
saveDownloadHistory &&
|
||||
!(preserveQualityVariant && result.optBoolean("already_exists", false))
|
||||
) {
|
||||
try {
|
||||
buildHistoryRow(effectiveInput, state).also {
|
||||
upsertHistory(
|
||||
context,
|
||||
it,
|
||||
deduplicateTrack = !preserveQualityVariant,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// History is bookkeeping; never fail (and never delete) a
|
||||
// finished download because the insert failed.
|
||||
android.util.Log.w(TAG, "History write failed (non-fatal): ${e.message}")
|
||||
null
|
||||
}
|
||||
val history = if (saveDownloadHistory) {
|
||||
buildHistoryRow(effectiveInput, state).also { upsertHistory(context, it) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -278,17 +233,13 @@ object NativeDownloadFinalizer {
|
||||
result.put("history_written", history != null)
|
||||
if (history != null) result.put("history_item", historyToJson(history))
|
||||
} catch (e: CancellationException) {
|
||||
if (!outputPublished) {
|
||||
cleanupFailedFinalizationOutput(context, result, initialPath, state.filePath)
|
||||
}
|
||||
cleanupFailedFinalizationOutput(context, result, initialPath, state.filePath)
|
||||
result.put("success", false)
|
||||
result.put("error", "Native finalization cancelled")
|
||||
result.put("error_type", "cancelled")
|
||||
result.put("native_finalized", false)
|
||||
} catch (e: Exception) {
|
||||
if (!outputPublished) {
|
||||
cleanupFailedFinalizationOutput(context, result, initialPath, state.filePath)
|
||||
}
|
||||
cleanupFailedFinalizationOutput(context, result, initialPath, state.filePath)
|
||||
result.put("success", false)
|
||||
result.put("error", "Native finalization failed: ${e.message}")
|
||||
result.put("error_type", "unknown")
|
||||
@@ -383,6 +334,7 @@ object NativeDownloadFinalizer {
|
||||
}
|
||||
|
||||
private fun currentStatus(@Suppress("UNUSED_PARAMETER") status: String) {
|
||||
// Kept as a narrow hook for future richer progress snapshots.
|
||||
}
|
||||
|
||||
private fun cleanupFailedFinalizationOutput(
|
||||
@@ -470,20 +422,16 @@ object NativeDownloadFinalizer {
|
||||
try {
|
||||
for (candidate in decryptionKeyCandidates(key)) {
|
||||
checkCancelled(shouldCancel)
|
||||
val attempts = mutableListOf<Triple<String, Boolean, Boolean>>()
|
||||
attempts.add(Triple(outputPath, preferredExt == ".flac", false))
|
||||
val attempts = mutableListOf<Pair<String, Boolean>>()
|
||||
attempts.add(outputPath to (preferredExt == ".flac"))
|
||||
if (preferredExt == ".flac") {
|
||||
attempts.add(Triple(buildOutputPath(localInput, ".m4a"), false, false))
|
||||
attempts.add(buildOutputPath(localInput, ".m4a") to false)
|
||||
}
|
||||
if (preferredExt == ".flac" || preferredExt == ".m4a") {
|
||||
attempts.add(Triple(buildOutputPath(localInput, ".mp4"), false, false))
|
||||
attempts.add(buildOutputPath(localInput, ".mp4") to false)
|
||||
}
|
||||
// MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4):
|
||||
// keeps the .mp4 filename but stores the codec params.
|
||||
attempts.add(Triple(buildOutputPath(localInput, ".mp4"), false, true))
|
||||
|
||||
for ((candidateOutput, mapAudioOnly, forceMov) in attempts) {
|
||||
val stagedOutput = stagedConversionPath(candidateOutput)
|
||||
for ((candidateOutput, mapAudioOnly) in attempts) {
|
||||
try {
|
||||
val audioMap = if (mapAudioOnly) "-map 0:a " else ""
|
||||
// Force the flac muxer when the target extension is
|
||||
@@ -491,27 +439,21 @@ object NativeDownloadFinalizer {
|
||||
// stream layout, producing FLAC-in-MP4 under a .flac
|
||||
// filename which downstream native FLAC tag writers
|
||||
// cannot read.
|
||||
val muxerOverride = when {
|
||||
forceMov -> "-f mov "
|
||||
candidateOutput.lowercase(Locale.ROOT).endsWith(".flac") -> "-f flac "
|
||||
else -> ""
|
||||
}
|
||||
val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${muxerOverride}${q(stagedOutput)} -y"
|
||||
val muxerOverride = if (candidateOutput.lowercase(Locale.ROOT).endsWith(".flac")) "-f flac " else ""
|
||||
val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${muxerOverride}${q(candidateOutput)} -y"
|
||||
val result = runFFmpeg(command, shouldCancel)
|
||||
lastOutput = result.second
|
||||
if (result.first && File(stagedOutput).exists() &&
|
||||
promoteStagedConversion(stagedOutput, candidateOutput)
|
||||
) {
|
||||
if (result.first && File(candidateOutput).exists()) {
|
||||
successPath = candidateOutput
|
||||
outputPath = candidateOutput
|
||||
break
|
||||
}
|
||||
File(stagedOutput).delete()
|
||||
File(candidateOutput).delete()
|
||||
} catch (e: CancellationException) {
|
||||
File(stagedOutput).delete()
|
||||
File(candidateOutput).delete()
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
File(stagedOutput).delete()
|
||||
File(candidateOutput).delete()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
@@ -560,31 +502,24 @@ object NativeDownloadFinalizer {
|
||||
val localInput = materializeForFFmpeg(context, input, state)
|
||||
val deleteLocalInput = state.filePath.startsWith("content://")
|
||||
val output = buildOutputPath(localInput, ext)
|
||||
val stagedOutput = stagedConversionPath(output)
|
||||
var adoptedOutput = false
|
||||
try {
|
||||
val command = if (format == "opus") {
|
||||
"-v error -hide_banner -i ${q(localInput)} -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a ${q(stagedOutput)} -y"
|
||||
"-v error -hide_banner -i ${q(localInput)} -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a ${q(output)} -y"
|
||||
} else if (format == "aac") {
|
||||
"-v error -hide_banner -i ${q(localInput)} -codec:a aac -b:a $bitrate -map 0:a -f mp4 ${q(stagedOutput)} -y"
|
||||
"-v error -hide_banner -i ${q(localInput)} -codec:a aac -b:a $bitrate -map 0:a -f mp4 ${q(output)} -y"
|
||||
} else {
|
||||
"-v error -hide_banner -i ${q(localInput)} -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 ${q(stagedOutput)} -y"
|
||||
"-v error -hide_banner -i ${q(localInput)} -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 ${q(output)} -y"
|
||||
}
|
||||
val result = runFFmpeg(command, shouldCancel)
|
||||
if (!result.first || !File(stagedOutput).exists()) {
|
||||
if (!result.first || !File(output).exists()) {
|
||||
throw IllegalStateException("HIGH conversion failed: ${result.second}")
|
||||
}
|
||||
if (!promoteStagedConversion(stagedOutput, output)) {
|
||||
throw IllegalStateException("failed to publish HIGH conversion output")
|
||||
}
|
||||
embedBasicMetadata(context, output, input, metadataFormat)
|
||||
replaceStatePath(context, input, state, output, deleteOld = true)
|
||||
adoptedOutput = true
|
||||
} finally {
|
||||
if (!adoptedOutput) {
|
||||
File(stagedOutput).delete()
|
||||
File(output).delete()
|
||||
}
|
||||
if (!adoptedOutput) File(output).delete()
|
||||
if (deleteLocalInput) File(localInput).delete()
|
||||
}
|
||||
state.quality = "$displayFormat ${bitrate.removeSuffix("k")}kbps"
|
||||
@@ -610,19 +545,12 @@ object NativeDownloadFinalizer {
|
||||
val localInput = materializeForFFmpeg(context, input, state)
|
||||
val deleteLocalInput = state.filePath.startsWith("content://")
|
||||
val output = buildOutputPath(localInput, ".flac")
|
||||
val stagedOutput = stagedConversionPath(output)
|
||||
var adoptedOutput = false
|
||||
try {
|
||||
val codec = probePrimaryAudioCodec(localInput, shouldCancel)
|
||||
val isAlreadyNativeFlac = codec == "flac" && isNativeFlacFile(localInput)
|
||||
if (!isLosslessAudioCodec(codec)) {
|
||||
Log.d(TAG, "Preserving native container; audio codec is ${codec.ifBlank { "unknown" }}")
|
||||
// The preserved stream is not FLAC but still carries the
|
||||
// requested .flac name. Rename to the real container so the
|
||||
// metadata/ReplayGain writers pick the right format — an
|
||||
// MP4 stream under a .flac name fails "fLaC head incorrect"
|
||||
// on every subsequent write and the file is never repaired.
|
||||
adoptPreservedContainerExtension(state, localInput, codec)
|
||||
return
|
||||
}
|
||||
if (isAlreadyNativeFlac) {
|
||||
@@ -630,11 +558,7 @@ object NativeDownloadFinalizer {
|
||||
val nativeFlacOutput = if (localInput.lowercase(Locale.ROOT).endsWith(".flac")) {
|
||||
localInput
|
||||
} else {
|
||||
File(localInput).copyTo(File(stagedOutput), overwrite = true)
|
||||
if (!promoteStagedConversion(stagedOutput, output)) {
|
||||
throw IllegalStateException("failed to publish native FLAC output")
|
||||
}
|
||||
output
|
||||
File(localInput).copyTo(File(output), overwrite = true).absolutePath
|
||||
}
|
||||
embedBasicMetadata(context, nativeFlacOutput, input, "flac")
|
||||
replaceStatePath(context, input, state, nativeFlacOutput, deleteOld = true)
|
||||
@@ -642,84 +566,21 @@ object NativeDownloadFinalizer {
|
||||
return
|
||||
}
|
||||
val result = runFFmpeg(
|
||||
"-v error -xerror -i ${q(localInput)} -c:a flac -compression_level 8 ${q(stagedOutput)} -y",
|
||||
"-v error -xerror -i ${q(localInput)} -c:a flac -compression_level 8 ${q(output)} -y",
|
||||
shouldCancel,
|
||||
)
|
||||
if (!result.first || !File(stagedOutput).exists()) {
|
||||
if (!result.first || !File(output).exists()) {
|
||||
throw IllegalStateException("container conversion failed: ${result.second}")
|
||||
}
|
||||
if (!promoteStagedConversion(stagedOutput, output)) {
|
||||
throw IllegalStateException("failed to publish container conversion output")
|
||||
}
|
||||
embedBasicMetadata(context, output, input, "flac")
|
||||
replaceStatePath(context, input, state, output, deleteOld = true)
|
||||
adoptedOutput = true
|
||||
} finally {
|
||||
if (!adoptedOutput) {
|
||||
File(stagedOutput).delete()
|
||||
File(output).delete()
|
||||
}
|
||||
if (!adoptedOutput) File(output).delete()
|
||||
if (deleteLocalInput) File(localInput).delete()
|
||||
}
|
||||
}
|
||||
|
||||
/// Renames a preserved lossy/unknown stream away from its requested .flac
|
||||
/// name to match its actual container (mirrors the Dart pipeline's
|
||||
/// post-download rename). Local files only: legacy content:// outputs are
|
||||
/// left untouched. No-op when the container cannot be identified.
|
||||
private fun adoptPreservedContainerExtension(
|
||||
state: FinalizeState,
|
||||
localInput: String,
|
||||
codec: String,
|
||||
) {
|
||||
if (state.filePath.startsWith("content://")) return
|
||||
val currentFile = File(state.filePath)
|
||||
if (!currentFile.exists()) return
|
||||
if (!currentFile.name.lowercase(Locale.ROOT).endsWith(".flac")) return
|
||||
|
||||
val newExt = when {
|
||||
codec == "aac" && isMP4ContainerFile(localInput) -> ".m4a"
|
||||
codec == "mp3" -> ".mp3"
|
||||
codec == "opus" -> ".opus"
|
||||
isMP4ContainerFile(localInput) -> ".m4a"
|
||||
else -> return
|
||||
}
|
||||
val renamed = File(
|
||||
currentFile.parentFile,
|
||||
currentFile.name.dropLast(".flac".length) + newExt,
|
||||
)
|
||||
if (renamed.exists() && !renamed.delete()) {
|
||||
Log.w(TAG, "Cannot adopt container extension; ${renamed.name} already exists")
|
||||
return
|
||||
}
|
||||
if (!currentFile.renameTo(renamed)) {
|
||||
Log.w(TAG, "Failed to rename preserved container to ${renamed.name}")
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "Preserved container renamed: ${currentFile.name} -> ${renamed.name}")
|
||||
state.filePath = renamed.absolutePath
|
||||
if (state.fileName.isNotBlank()) {
|
||||
state.fileName = renamed.name
|
||||
}
|
||||
state.audioCodec = normalizeAudioCodec(codec)
|
||||
}
|
||||
|
||||
private fun isMP4ContainerFile(path: String): Boolean {
|
||||
return try {
|
||||
File(path).inputStream().use { stream ->
|
||||
val header = ByteArray(12)
|
||||
val read = stream.read(header)
|
||||
read >= 8 &&
|
||||
header[4] == 'f'.code.toByte() &&
|
||||
header[5] == 't'.code.toByte() &&
|
||||
header[6] == 'y'.code.toByte() &&
|
||||
header[7] == 'p'.code.toByte()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun finalizeMetadata(context: Context, input: FinalizeInput, state: FinalizeState) {
|
||||
if (!input.request.optBoolean("embed_metadata", false)) return
|
||||
if (!state.filePath.startsWith("content://")) {
|
||||
@@ -807,7 +668,6 @@ object NativeDownloadFinalizer {
|
||||
val uri = Uri.parse(path)
|
||||
context.contentResolver.openOutputStream(uri, "wt")?.use { output ->
|
||||
File(tempPath).inputStream().use { input -> input.copyTo(output) }
|
||||
SafDownloadHandler.syncOutputStream(output)
|
||||
} ?: throw IllegalStateException("failed to write ReplayGain back to SAF")
|
||||
} finally {
|
||||
File(tempPath).delete()
|
||||
@@ -928,129 +788,6 @@ object NativeDownloadFinalizer {
|
||||
return nonPlaceholderQuality(storedQuality) ?: normalizeOptional(storedQuality)
|
||||
}
|
||||
|
||||
private fun qualityVariantFilenameLabel(state: FinalizeState): String? {
|
||||
val measuredQuality = state.quality
|
||||
if (isLossyAudioCodec(state.audioCodec)) {
|
||||
val bitrate = state.bitrateKbps ?: Regex(
|
||||
"\\b(\\d+)\\s*kbps\\b",
|
||||
RegexOption.IGNORE_CASE,
|
||||
).find(measuredQuality)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||
return bitrate?.takeIf { it >= 16 }?.let { "${it}kbps" }
|
||||
}
|
||||
|
||||
var bitDepth = state.bitDepth
|
||||
var sampleRate = state.sampleRate
|
||||
if (bitDepth == null || sampleRate == null) {
|
||||
val match = Regex(
|
||||
"\\b(\\d+)\\s*(?:-|\\s)?bit\\s*[/_-]\\s*(\\d+(?:\\.\\d+)?)\\s*k?hz\\b",
|
||||
RegexOption.IGNORE_CASE,
|
||||
).find(measuredQuality)
|
||||
bitDepth = bitDepth ?: match?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||
sampleRate = sampleRate ?: match?.groupValues?.getOrNull(2)?.toDoubleOrNull()?.let { rate ->
|
||||
if (rate < 1000) (rate * 1000).roundToInt() else rate.roundToInt()
|
||||
}
|
||||
}
|
||||
if (bitDepth == null || bitDepth <= 0 || sampleRate == null || sampleRate <= 0) return null
|
||||
val khz = sampleRate / 1000.0
|
||||
val precision = if (sampleRate % 1000 == 0) 0 else 1
|
||||
val sampleRateLabel = "%.${precision}f".format(Locale.US, khz)
|
||||
return "${bitDepth}bit-${sampleRateLabel}kHz"
|
||||
}
|
||||
|
||||
private fun finalizeQualityVariantFilename(
|
||||
context: Context,
|
||||
input: FinalizeInput,
|
||||
state: FinalizeState,
|
||||
) {
|
||||
if (!input.request.optBoolean("allow_quality_variant", false)) return
|
||||
val stagingLabel = input.request.optString("quality_variant", "").trim()
|
||||
val qualityLabel = qualityVariantFilenameLabel(state)
|
||||
if (qualityLabel == null) {
|
||||
Log.w(TAG, "Keeping temporary quality label because final audio specifications are unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
val preferredName = applyQualityVariantFilenameLabel(
|
||||
fileName = state.fileName,
|
||||
stagingLabel = stagingLabel,
|
||||
qualityLabel = qualityLabel,
|
||||
)
|
||||
if (preferredName == state.fileName) return
|
||||
input.result.put("quality_variant_file_name", preferredName)
|
||||
if (isDeferredSafPublish(input)) {
|
||||
state.fileName = preferredName
|
||||
return
|
||||
}
|
||||
|
||||
if (state.filePath.startsWith("content://")) {
|
||||
val tempPath = SafDownloadHandler.copyContentUriToTemp(context, state.filePath) ?: return
|
||||
try {
|
||||
val writeResult = SafDownloadHandler.writeFileToSafUnique(
|
||||
context = context,
|
||||
treeUriStr = input.request.optString("saf_tree_uri", ""),
|
||||
relativeDir = input.request.optString("saf_relative_dir", ""),
|
||||
fileName = preferredName,
|
||||
mimeType = mimeTypeForExt(File(preferredName).extension),
|
||||
srcPath = tempPath,
|
||||
preservedSuffix = qualityLabel,
|
||||
) ?: return
|
||||
SafDownloadHandler.deleteContentUri(context, state.filePath)
|
||||
state.filePath = writeResult.uri
|
||||
state.fileName = writeResult.fileName
|
||||
} finally {
|
||||
File(tempPath).delete()
|
||||
}
|
||||
} else {
|
||||
val source = File(state.filePath)
|
||||
val target = uniqueLocalFile(source.parentFile, preferredName)
|
||||
if (!source.renameTo(target)) {
|
||||
Log.w(TAG, "Could not rename quality variant output: ${source.absolutePath}")
|
||||
return
|
||||
}
|
||||
state.filePath = target.absolutePath
|
||||
state.fileName = target.name
|
||||
}
|
||||
|
||||
input.result.put("file_path", state.filePath)
|
||||
input.result.put("file_name", state.fileName)
|
||||
input.result.optJSONObject("replaygain")?.let { replayGain ->
|
||||
replayGain.put("file_path", state.filePath)
|
||||
replayGain.put("file_name", state.fileName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyQualityVariantFilenameLabel(
|
||||
fileName: String,
|
||||
stagingLabel: String,
|
||||
qualityLabel: String,
|
||||
): String {
|
||||
if (stagingLabel.isNotEmpty() && fileName.contains(stagingLabel)) {
|
||||
return fileName.replace(stagingLabel, qualityLabel)
|
||||
}
|
||||
if (fileName.contains(qualityLabel)) return fileName
|
||||
val dotIndex = fileName.lastIndexOf('.')
|
||||
val hasExtension = dotIndex > 0
|
||||
val stem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
|
||||
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
|
||||
return "$stem - $qualityLabel$extension"
|
||||
}
|
||||
|
||||
private fun uniqueLocalFile(parent: File?, preferredName: String): File {
|
||||
val directory = parent ?: return File(preferredName)
|
||||
var candidate = File(directory, preferredName)
|
||||
if (!candidate.exists()) return candidate
|
||||
val dotIndex = preferredName.lastIndexOf('.')
|
||||
val hasExtension = dotIndex > 0
|
||||
val stem = if (hasExtension) preferredName.substring(0, dotIndex) else preferredName
|
||||
val extension = if (hasExtension) preferredName.substring(dotIndex) else ""
|
||||
var counter = 2
|
||||
while (candidate.exists()) {
|
||||
candidate = File(directory, "$stem ($counter)$extension")
|
||||
counter++
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
private fun audioFormatForCodec(codec: String?): String? {
|
||||
return when (normalizeAudioCodec(codec)) {
|
||||
"flac" -> "FLAC"
|
||||
@@ -1357,55 +1094,39 @@ object NativeDownloadFinalizer {
|
||||
val shouldEmbedLyrics = shouldResolveLyrics &&
|
||||
lyrics.isNotBlank() &&
|
||||
lyrics != "[instrumental:true]"
|
||||
// FLAC, MP3, Opus, and M4A all have native Go tag writers that edit the
|
||||
// tag block atomically without an ffmpeg remux (which drops foreign
|
||||
// frames and rewrites the whole container). The Go side answers
|
||||
// method=ffmpeg when it cannot handle the file natively.
|
||||
if (format == "flac" || format == "mp3" || format == "opus" || format == "m4a") {
|
||||
val nativeCover = downloadCoverForMetadata(context, input)
|
||||
val handledNatively = try {
|
||||
val fields = JSONObject()
|
||||
.put("title", title)
|
||||
.put("artist", artist)
|
||||
.put("album", album)
|
||||
.put("album_artist", albumArtist)
|
||||
.put("date", date)
|
||||
.put("isrc", isrc)
|
||||
.put("composer", composer)
|
||||
.put("genre", genre)
|
||||
.put("label", label)
|
||||
.put("copyright", copyright)
|
||||
if (trackNumberValue > 0) fields.put("track_number", trackNumberValue.toString())
|
||||
if (totalTracksValue > 0) fields.put("track_total", totalTracksValue.toString())
|
||||
if (discNumberValue > 0) fields.put("disc_number", discNumberValue.toString())
|
||||
if (totalDiscsValue > 0) fields.put("disc_total", totalDiscsValue.toString())
|
||||
if (nativeCover != null) fields.put("cover_path", nativeCover.absolutePath)
|
||||
if (shouldEmbedLyrics) {
|
||||
fields.put("lyrics", lyrics)
|
||||
fields.put("unsyncedlyrics", lyrics)
|
||||
}
|
||||
val response = Gobackend.editFileMetadata(path, fields.toString())
|
||||
val method = try {
|
||||
JSONObject(response).optString("method", "")
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
method != "ffmpeg"
|
||||
} catch (e: Exception) {
|
||||
if (format == "flac") throw e
|
||||
Log.w(TAG, "Native tag embed failed for $format: ${e.message}; falling back to ffmpeg")
|
||||
false
|
||||
} finally {
|
||||
nativeCover?.delete()
|
||||
if (format == "flac") {
|
||||
val coverFile = downloadCoverForMetadata(context, input)
|
||||
val fields = JSONObject()
|
||||
.put("title", title)
|
||||
.put("artist", artist)
|
||||
.put("album", album)
|
||||
.put("album_artist", albumArtist)
|
||||
.put("date", date)
|
||||
.put("isrc", isrc)
|
||||
.put("composer", composer)
|
||||
.put("genre", genre)
|
||||
.put("label", label)
|
||||
.put("copyright", copyright)
|
||||
if (trackNumberValue > 0) fields.put("track_number", trackNumberValue.toString())
|
||||
if (totalTracksValue > 0) fields.put("track_total", totalTracksValue.toString())
|
||||
if (discNumberValue > 0) fields.put("disc_number", discNumberValue.toString())
|
||||
if (totalDiscsValue > 0) fields.put("disc_total", totalDiscsValue.toString())
|
||||
if (coverFile != null) fields.put("cover_path", coverFile.absolutePath)
|
||||
if (shouldEmbedLyrics) {
|
||||
fields.put("lyrics", lyrics)
|
||||
fields.put("unsyncedlyrics", lyrics)
|
||||
}
|
||||
if (handledNatively) return
|
||||
try {
|
||||
Gobackend.editFileMetadata(path, fields.toString())
|
||||
} finally {
|
||||
coverFile?.delete()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val ext = normalizeExt(File(path).extension).ifBlank { ".tmp" }
|
||||
val inputFile = File(path)
|
||||
// ".partial<ext>" keeps the temp invisible to library scans while FFmpeg
|
||||
// still infers the muxer from the real trailing extension.
|
||||
val temp = File(inputFile.parentFile, "${inputFile.nameWithoutExtension}_tagged.partial$ext")
|
||||
val temp = File(inputFile.parentFile, "${inputFile.nameWithoutExtension}_tagged$ext")
|
||||
val isM4a = format == "m4a"
|
||||
val isOpus = format == "opus"
|
||||
val coverFile = if (isM4a || isOpus) downloadCoverForMetadata(context, input) else null
|
||||
@@ -1438,34 +1159,20 @@ object NativeDownloadFinalizer {
|
||||
val mp3Flags = if (format == "mp3") "-id3v2_version 3 " else ""
|
||||
var adoptedTemp = false
|
||||
var originalDeleted = false
|
||||
|
||||
fun buildEmbedCommand(forceMov: Boolean): String {
|
||||
return if (isM4a && coverFile != null) {
|
||||
try {
|
||||
val command = if (isM4a && coverFile != null) {
|
||||
"-v error -hide_banner -i ${q(path)} -i ${q(coverFile.absolutePath)} " +
|
||||
"-map 0:a -c:a copy -map_metadata 0 -map 1:v -c:v copy " +
|
||||
"-disposition:v:0 attached_pic " +
|
||||
"-metadata:s:v ${q("title=Album cover")} " +
|
||||
"-metadata:s:v ${q("comment=Cover (front)")} " +
|
||||
"$metadataArgs -f ${if (forceMov) "mov" else "mp4"} ${q(temp.absolutePath)} -y"
|
||||
"$metadataArgs -f mp4 ${q(temp.absolutePath)} -y"
|
||||
} else {
|
||||
val movFlag = if (forceMov) "-f mov " else ""
|
||||
"-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags$movFlag${q(temp.absolutePath)} -y"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
var result = runFFmpeg(buildEmbedCommand(false))
|
||||
// MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4).
|
||||
if (!result.first && (isM4a || ext.equals(".mp4", ignoreCase = true))) {
|
||||
temp.delete()
|
||||
result = runFFmpeg(buildEmbedCommand(true))
|
||||
"-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags${q(temp.absolutePath)} -y"
|
||||
}
|
||||
val result = runFFmpeg(command)
|
||||
if (result.first && temp.exists()) {
|
||||
fsyncQuietly(temp)
|
||||
// Rename directly over the original: a process kill between a
|
||||
// delete-first and the rename would lose the file entirely.
|
||||
adoptedTemp = temp.renameTo(inputFile)
|
||||
if (!adoptedTemp && inputFile.delete()) {
|
||||
if (inputFile.delete()) {
|
||||
originalDeleted = true
|
||||
adoptedTemp = temp.renameTo(inputFile)
|
||||
}
|
||||
@@ -1478,18 +1185,6 @@ object NativeDownloadFinalizer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort fsync so a file's bytes are durable before it is renamed
|
||||
* over another file; fsync on a fresh handle flushes the page cache pages
|
||||
* written earlier by ffmpeg in this process.
|
||||
*/
|
||||
private fun fsyncQuietly(file: File) {
|
||||
try {
|
||||
RandomAccessFile(file, "rw").use { it.fd.sync() }
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMetadataBlockPicture(coverFile: File): String? {
|
||||
return try {
|
||||
if (!coverFile.exists() || coverFile.length() <= 0L) return null
|
||||
@@ -1662,6 +1357,7 @@ object NativeDownloadFinalizer {
|
||||
val handled = mutableSetOf<String>()
|
||||
val pump = Thread {
|
||||
while (running.get()) {
|
||||
if (shouldCancel()) return@Thread
|
||||
try {
|
||||
val raw = Gobackend.getAllPendingFFmpegCommandsJSON()
|
||||
val commands = org.json.JSONArray(raw)
|
||||
@@ -1673,39 +1369,20 @@ object NativeDownloadFinalizer {
|
||||
continue
|
||||
}
|
||||
handled.add(id)
|
||||
// Every claimed command must get a result delivered to
|
||||
// the Go side, even on failure or cancellation: the
|
||||
// backend blocks until one arrives and never retries a
|
||||
// claimed id, so bailing out here would strand the
|
||||
// gomobile call the main thread is sitting in forever.
|
||||
val result = try {
|
||||
if (shouldCancel()) {
|
||||
Pair(false, "cancelled")
|
||||
} else {
|
||||
runFFmpeg(commandLine, shouldCancel)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Pair(false, e.message ?: "FFmpeg execution failed")
|
||||
}
|
||||
try {
|
||||
Gobackend.setFFmpegCommandResultByID(
|
||||
id,
|
||||
result.first,
|
||||
result.second,
|
||||
if (result.first) "" else result.second,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to deliver FFmpeg result for $id: ${e.message}")
|
||||
}
|
||||
val result = runFFmpeg(commandLine, shouldCancel)
|
||||
Gobackend.setFFmpegCommandResultByID(
|
||||
id,
|
||||
result.first,
|
||||
result.second,
|
||||
if (result.first) "" else result.second,
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
try {
|
||||
Thread.sleep(100)
|
||||
} catch (_: InterruptedException) {
|
||||
// Keep pumping until `running` flips: on cancel the Go call
|
||||
// may still be waiting for a result for an in-flight
|
||||
// command, and it is delivered as failed above.
|
||||
return@Thread
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1719,28 +1396,6 @@ object NativeDownloadFinalizer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Staged sibling name for conversion outputs: "song.flac" -> "song.partial.flac".
|
||||
* The ".partial<ext>" shape is ignored by library scans and duplicate checks,
|
||||
* while the real trailing extension still lets FFmpeg infer the muxer. A
|
||||
* process kill mid-conversion therefore never leaves a partial file under a
|
||||
* real audio name in the user's music folder.
|
||||
*/
|
||||
private fun stagedConversionPath(finalPath: String): String {
|
||||
val file = File(finalPath)
|
||||
val ext = file.extension
|
||||
val name = if (ext.isBlank()) "${file.name}.partial" else "${file.nameWithoutExtension}.partial.$ext"
|
||||
return File(file.parentFile, name).absolutePath
|
||||
}
|
||||
|
||||
private fun promoteStagedConversion(stagedPath: String, finalPath: String): Boolean {
|
||||
val staged = File(stagedPath)
|
||||
fsyncQuietly(staged)
|
||||
val final = File(finalPath)
|
||||
if (staged.renameTo(final)) return true
|
||||
return final.delete() && staged.renameTo(final)
|
||||
}
|
||||
|
||||
private fun buildOutputPath(inputPath: String, extension: String): String {
|
||||
val ext = normalizeExt(extension).ifBlank { ".tmp" }
|
||||
val file = File(inputPath)
|
||||
@@ -1752,8 +1407,7 @@ object NativeDownloadFinalizer {
|
||||
|
||||
private fun desiredFileName(input: FinalizeInput, state: FinalizeState, extension: String): String {
|
||||
val ext = normalizeExt(extension).ifBlank { normalizeExt(File(state.fileName).extension).ifBlank { ".flac" } }
|
||||
val rawName = input.result.optString("quality_variant_file_name", "")
|
||||
.ifBlank { input.request.optString("saf_file_name", "") }
|
||||
val rawName = input.request.optString("saf_file_name", "")
|
||||
.ifBlank { state.fileName }
|
||||
.ifBlank { "${trackString(input, "artistName", input.request.optString("artist_name", "Artist"))} - ${trackString(input, "name", input.request.optString("track_name", "Track"))}" }
|
||||
val knownExts = listOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg", ".lrc")
|
||||
@@ -1903,46 +1557,24 @@ object NativeDownloadFinalizer {
|
||||
val relativeDir = input.result.optString("saf_relative_dir", "")
|
||||
.ifBlank { input.request.optString("saf_relative_dir", "") }
|
||||
val mimeType = mimeTypeForExt(outputFile.extension)
|
||||
val preserveQualityVariant = input.request.optBoolean("allow_quality_variant", false)
|
||||
val uniqueWrite = if (preserveQualityVariant) {
|
||||
SafDownloadHandler.writeFileToSafUnique(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = finalName,
|
||||
mimeType = mimeType,
|
||||
srcPath = outputFile.absolutePath,
|
||||
preservedSuffix = qualityVariantFilenameLabel(state).orEmpty(),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val newUri = uniqueWrite?.uri ?: if (!preserveQualityVariant) {
|
||||
SafDownloadHandler.writeFileToSaf(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = finalName,
|
||||
mimeType = mimeType,
|
||||
srcPath = outputFile.absolutePath,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
} ?: throw IllegalStateException("failed to publish deferred SAF output")
|
||||
val publishedName = uniqueWrite?.fileName ?: finalName
|
||||
val newUri = SafDownloadHandler.writeFileToSaf(
|
||||
context = context,
|
||||
treeUriStr = treeUri,
|
||||
relativeDir = relativeDir,
|
||||
fileName = finalName,
|
||||
mimeType = mimeType,
|
||||
srcPath = outputFile.absolutePath,
|
||||
) ?: throw IllegalStateException("failed to publish deferred SAF output")
|
||||
|
||||
Log.i(TAG, "Published deferred SAF output once: file=$publishedName bytes=${outputFile.length()}")
|
||||
Log.i(TAG, "Published deferred SAF output once: file=$finalName bytes=${outputFile.length()}")
|
||||
outputFile.delete()
|
||||
state.filePath = newUri
|
||||
state.fileName = publishedName
|
||||
state.fileName = finalName
|
||||
input.result.put("file_path", newUri)
|
||||
input.result.put("file_name", publishedName)
|
||||
input.result.put("file_name", finalName)
|
||||
input.result.optJSONObject("replaygain")?.let { replayGain ->
|
||||
replayGain.put("file_path", newUri)
|
||||
replayGain.put("file_name", publishedName)
|
||||
}
|
||||
if (state.pendingExternalLrc != null) {
|
||||
state.pendingExternalLrcFileName = "${publishedName.replace(Regex("\\.[^.]+$"), "")}.lrc"
|
||||
replayGain.put("file_name", finalName)
|
||||
}
|
||||
input.result.put("saf_deferred_published", true)
|
||||
publishPendingDeferredExternalLrc(context, input, state)
|
||||
@@ -2084,11 +1716,7 @@ object NativeDownloadFinalizer {
|
||||
return values
|
||||
}
|
||||
|
||||
private fun upsertHistory(
|
||||
context: Context,
|
||||
values: ContentValues,
|
||||
deduplicateTrack: Boolean = true,
|
||||
) {
|
||||
private fun upsertHistory(context: Context, values: ContentValues) {
|
||||
val dbFile = File(File(context.applicationInfo.dataDir, "app_flutter"), "history.db")
|
||||
dbFile.parentFile?.mkdirs()
|
||||
val db = SQLiteDatabase.openDatabase(
|
||||
@@ -2141,12 +1769,7 @@ object NativeDownloadFinalizer {
|
||||
genre TEXT,
|
||||
composer TEXT,
|
||||
label TEXT,
|
||||
copyright TEXT,
|
||||
spotify_id_norm TEXT,
|
||||
isrc_norm TEXT,
|
||||
match_key TEXT,
|
||||
album_key TEXT,
|
||||
search_text TEXT
|
||||
copyright TEXT
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
@@ -2163,8 +1786,6 @@ object NativeDownloadFinalizer {
|
||||
ensureHistoryColumn(db, "spotify_id_norm", "ALTER TABLE history ADD COLUMN spotify_id_norm TEXT")
|
||||
ensureHistoryColumn(db, "isrc_norm", "ALTER TABLE history ADD COLUMN isrc_norm TEXT")
|
||||
ensureHistoryColumn(db, "match_key", "ALTER TABLE history ADD COLUMN match_key TEXT")
|
||||
ensureHistoryColumn(db, "album_key", "ALTER TABLE history ADD COLUMN album_key TEXT")
|
||||
ensureHistoryColumn(db, "search_text", "ALTER TABLE history ADD COLUMN search_text TEXT")
|
||||
ensureHistoryPathKeyTable(db)
|
||||
if (needsBackfill) {
|
||||
backfillNormalizedHistoryColumns(db)
|
||||
@@ -2179,9 +1800,8 @@ object NativeDownloadFinalizer {
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_spotify_id_norm ON history(spotify_id_norm)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_isrc_norm ON history(isrc_norm)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_match_key ON history(match_key)")
|
||||
db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_album_key ON history(album_key)")
|
||||
if (db.version < HISTORY_SCHEMA_VERSION) db.version = HISTORY_SCHEMA_VERSION
|
||||
if (deduplicateTrack) deleteDuplicateHistoryRows(db, values)
|
||||
deleteDuplicateHistoryRows(db, values)
|
||||
db.insertWithOnConflict("history", null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
replaceHistoryPathKeys(db, values.getAsString("id"), values.getAsString("file_path"))
|
||||
db.setTransactionSuccessful()
|
||||
@@ -2306,8 +1926,8 @@ object NativeDownloadFinalizer {
|
||||
private fun backfillNormalizedHistoryColumns(db: SQLiteDatabase) {
|
||||
db.query(
|
||||
"history",
|
||||
arrayOf("id", "spotify_id", "isrc", "track_name", "artist_name", "album_name", "album_artist"),
|
||||
"spotify_id_norm IS NULL OR isrc_norm IS NULL OR match_key IS NULL OR album_key IS NULL OR search_text IS NULL",
|
||||
arrayOf("id", "spotify_id", "isrc", "track_name", "artist_name"),
|
||||
"spotify_id_norm IS NULL OR isrc_norm IS NULL OR match_key IS NULL",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
@@ -2318,8 +1938,6 @@ object NativeDownloadFinalizer {
|
||||
val isrcIndex = cursor.getColumnIndex("isrc")
|
||||
val trackIndex = cursor.getColumnIndex("track_name")
|
||||
val artistIndex = cursor.getColumnIndex("artist_name")
|
||||
val albumIndex = cursor.getColumnIndex("album_name")
|
||||
val albumArtistIndex = cursor.getColumnIndex("album_artist")
|
||||
while (cursor.moveToNext()) {
|
||||
if (idIndex < 0) continue
|
||||
val values = ContentValues()
|
||||
@@ -2327,18 +1945,9 @@ object NativeDownloadFinalizer {
|
||||
val isrc = cursor.getNullableString(isrcIndex)
|
||||
val trackName = cursor.getNullableString(trackIndex)
|
||||
val artistName = cursor.getNullableString(artistIndex)
|
||||
val albumName = cursor.getNullableString(albumIndex)
|
||||
val albumArtist = cursor.getNullableString(albumArtistIndex)
|
||||
values.put("spotify_id_norm", normalizeSpotifyId(spotifyId))
|
||||
values.put("isrc_norm", normalizeIsrc(isrc))
|
||||
values.put("match_key", matchKeyFor(trackName, artistName))
|
||||
putAlbumSearchHistoryColumns(
|
||||
values,
|
||||
trackName = trackName,
|
||||
artistName = artistName,
|
||||
albumName = albumName,
|
||||
albumArtist = albumArtist,
|
||||
)
|
||||
db.update("history", values, "id = ?", arrayOf(cursor.getString(idIndex)))
|
||||
}
|
||||
}
|
||||
@@ -2375,35 +1984,6 @@ object NativeDownloadFinalizer {
|
||||
"match_key",
|
||||
matchKeyFor(values.getAsString("track_name"), values.getAsString("artist_name")),
|
||||
)
|
||||
putAlbumSearchHistoryColumns(
|
||||
values,
|
||||
trackName = values.getAsString("track_name"),
|
||||
artistName = values.getAsString("artist_name"),
|
||||
albumName = values.getAsString("album_name"),
|
||||
albumArtist = values.getAsString("album_artist"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun putAlbumSearchHistoryColumns(
|
||||
values: ContentValues,
|
||||
trackName: String?,
|
||||
artistName: String?,
|
||||
albumName: String?,
|
||||
albumArtist: String?,
|
||||
) {
|
||||
val track = normalizeLookupText(trackName)
|
||||
val artist = normalizeLookupText(artistName)
|
||||
val album = normalizeLookupText(albumName)
|
||||
val resolvedAlbumArtist = normalizeLookupText(
|
||||
albumArtist?.takeIf { it.trim().isNotEmpty() } ?: artistName,
|
||||
)
|
||||
values.put("album_key", "$album|$resolvedAlbumArtist")
|
||||
values.put(
|
||||
"search_text",
|
||||
listOf(track, artist, album, resolvedAlbumArtist)
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString(" "),
|
||||
)
|
||||
}
|
||||
|
||||
private fun normalizeLookupText(value: String?): String =
|
||||
@@ -2630,5 +2210,22 @@ object NativeDownloadFinalizer {
|
||||
return if (trimmed.equals("null", ignoreCase = true)) "" else trimmed
|
||||
}
|
||||
|
||||
private fun normalizeExt(ext: String?): String {
|
||||
val trimmed = ext?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return ""
|
||||
return if (trimmed.startsWith(".")) trimmed.lowercase(Locale.ROOT) else ".${trimmed.lowercase(Locale.ROOT)}"
|
||||
}
|
||||
|
||||
private fun mimeTypeForExt(ext: String?): String {
|
||||
return when (normalizeExt(ext)) {
|
||||
".m4a", ".mp4" -> "audio/mp4"
|
||||
".mp3" -> "audio/mpeg"
|
||||
".opus", ".ogg" -> "audio/ogg"
|
||||
".flac" -> "audio/flac"
|
||||
".lrc" -> "application/octet-stream"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
private fun q(value: String): String = "\"${value.replace("\"", "\\\"")}\""
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
internal object NativeWorkerPolicy {
|
||||
const val MAX_RATE_LIMIT_RETRIES = 1
|
||||
private const val DEFAULT_RATE_LIMIT_DELAY_SECONDS = 30
|
||||
private const val MIN_RATE_LIMIT_DELAY_SECONDS = 5
|
||||
private const val MAX_RATE_LIMIT_DELAY_SECONDS = 300
|
||||
|
||||
private val retryAfterPattern = Regex(
|
||||
"""retry[- ]?after(?: seconds)?[:= ]+(\d+)""",
|
||||
RegexOption.IGNORE_CASE,
|
||||
)
|
||||
|
||||
fun shouldRetryRateLimit(
|
||||
errorType: String?,
|
||||
errorMessage: String?,
|
||||
attempts: Int,
|
||||
): Boolean {
|
||||
if (attempts >= MAX_RATE_LIMIT_RETRIES) return false
|
||||
if (errorType.equals("rate_limit", ignoreCase = true)) return true
|
||||
|
||||
val message = errorMessage.orEmpty()
|
||||
return message.contains("429") ||
|
||||
message.contains("rate limit", ignoreCase = true) ||
|
||||
message.contains("too many requests", ignoreCase = true)
|
||||
}
|
||||
|
||||
fun rateLimitDelaySeconds(
|
||||
retryAfterSeconds: Int?,
|
||||
errorMessage: String?,
|
||||
): Int {
|
||||
val parsedFromMessage = retryAfterPattern
|
||||
.find(errorMessage.orEmpty())
|
||||
?.groupValues
|
||||
?.getOrNull(1)
|
||||
?.toIntOrNull()
|
||||
return (retryAfterSeconds?.takeIf { it > 0 }
|
||||
?: parsedFromMessage
|
||||
?: DEFAULT_RATE_LIMIT_DELAY_SECONDS)
|
||||
.coerceIn(
|
||||
MIN_RATE_LIMIT_DELAY_SECONDS,
|
||||
MAX_RATE_LIMIT_DELAY_SECONDS,
|
||||
)
|
||||
}
|
||||
|
||||
fun isVerificationRequired(
|
||||
errorType: String?,
|
||||
errorMessage: String?,
|
||||
): Boolean {
|
||||
if (errorType.equals("verification_required", ignoreCase = true)) {
|
||||
return true
|
||||
}
|
||||
val message = errorMessage.orEmpty()
|
||||
return message.contains("verification required", ignoreCase = true) ||
|
||||
message.contains("challenge required", ignoreCase = true)
|
||||
}
|
||||
|
||||
fun requiresWifi(downloadNetworkMode: String?): Boolean =
|
||||
downloadNetworkMode.equals("wifi_only", ignoreCase = true)
|
||||
|
||||
fun shouldPauseForNetwork(
|
||||
downloadNetworkMode: String?,
|
||||
hasWifi: Boolean,
|
||||
): Boolean = requiresWifi(downloadNetworkMode) && !hasWifi
|
||||
|
||||
fun shouldNotifyQueueComplete(
|
||||
cancelRequested: Boolean,
|
||||
completed: Int,
|
||||
failed: Int,
|
||||
): Boolean = !cancelRequested && completed + failed > 0
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -19,39 +17,6 @@ object SafDownloadHandler {
|
||||
private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
|
||||
private const val STAGED_SAF_MIME_TYPE = "application/octet-stream"
|
||||
|
||||
// Serializes the exists-check/create/write/publish sequence per target
|
||||
// file so concurrent downloads that sanitize to the same display name
|
||||
// cannot interleave writes into one document. Different names keep
|
||||
// downloading in parallel; the second same-name caller blocks, then hits
|
||||
// the exists check and reports already_exists.
|
||||
private val safNameLocks = java.util.concurrent.ConcurrentHashMap<String, Any>()
|
||||
|
||||
data class UniqueWriteResult(val uri: String, val fileName: String)
|
||||
|
||||
private fun <T> withSafNameLock(
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
fileName: String,
|
||||
block: () -> T
|
||||
): T {
|
||||
val key = "$treeUriStr|$relativeDir|${fileName.lowercase(Locale.ROOT)}"
|
||||
val lock = safNameLocks.computeIfAbsent(key) { Any() }
|
||||
return synchronized(lock) { block() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Flushes and fsyncs a SAF output stream so the copied bytes are durable
|
||||
* before the staged document is renamed to its final name; without this a
|
||||
* power loss right after the rename can leave a truncated "complete" file.
|
||||
*/
|
||||
fun syncOutputStream(output: OutputStream) {
|
||||
try {
|
||||
output.flush()
|
||||
(output as? FileOutputStream)?.fd?.sync()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
|
||||
val req = JSONObject(requestJson)
|
||||
val storageMode = req.optString("storage_mode", "")
|
||||
@@ -60,34 +25,13 @@ object SafDownloadHandler {
|
||||
return downloader(requestJson)
|
||||
}
|
||||
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
|
||||
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
|
||||
val fileName = buildSafFileName(req, outputExt)
|
||||
return withSafNameLock(treeUriStr, relativeDir, fileName) {
|
||||
handleSafLocked(context, req, downloader, treeUriStr, relativeDir, outputExt, fileName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSafLocked(
|
||||
context: Context,
|
||||
req: JSONObject,
|
||||
downloader: (String) -> String,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
outputExt: String,
|
||||
fileName: String
|
||||
): String {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val mimeType = mimeTypeForExt(outputExt)
|
||||
val fileName = buildSafFileName(req, outputExt)
|
||||
val deferSafPublish = req.optBoolean("defer_saf_publish", false)
|
||||
// Downloads are always written under a staged ".partial" name so a
|
||||
// killed process can never leave a half-written file under the final
|
||||
// name (which the exists check would then accept as complete forever).
|
||||
// Callers that set stage_saf_output own the promotion to the final
|
||||
// name (the native finalizer); for everyone else — the foreground
|
||||
// Dart queue — this handler promotes right after a successful write.
|
||||
val finalizerPromotesStaged = req.optBoolean("stage_saf_output", false) && !deferSafPublish
|
||||
val useStagedOutput = !deferSafPublish
|
||||
val useStagedOutput = req.optBoolean("stage_saf_output", false) && !deferSafPublish
|
||||
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName) else fileName
|
||||
val stagedMimeType = if (useStagedOutput) STAGED_SAF_MIME_TYPE else mimeType
|
||||
|
||||
@@ -95,7 +39,9 @@ object SafDownloadHandler {
|
||||
if (existingDir != null) {
|
||||
val existing = existingDir.findFile(fileName)
|
||||
if (existing != null && existing.isFile && existing.length() > 0) {
|
||||
deleteStaleStagedFiles(existingDir, fileName, outputExt)
|
||||
if (useStagedOutput || deferSafPublish) {
|
||||
deleteStaleStagedFiles(existingDir, fileName, outputExt)
|
||||
}
|
||||
val obj = JSONObject()
|
||||
obj.put("success", true)
|
||||
obj.put("message", "File already exists")
|
||||
@@ -144,11 +90,6 @@ object SafDownloadHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any stale partial from a previous killed attempt before
|
||||
// creating the staged document: reusing it would let a shorter new
|
||||
// write leave the old tail bytes in place (fd truncation is
|
||||
// best-effort on some providers).
|
||||
deleteStaleStagedFiles(targetDir, fileName, outputExt)
|
||||
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
|
||||
?: return errorJson("Failed to create SAF file")
|
||||
|
||||
@@ -164,7 +105,6 @@ object SafDownloadHandler {
|
||||
val response = downloader(req.toString())
|
||||
val respObj = JSONObject(response)
|
||||
if (respObj.optBoolean("success", false)) {
|
||||
var finalFileName = fileName
|
||||
val goFilePath = respObj.optString("file_path", "")
|
||||
if (goFilePath.isNotEmpty() &&
|
||||
!goFilePath.startsWith("content://") &&
|
||||
@@ -198,13 +138,11 @@ object SafDownloadHandler {
|
||||
document.delete()
|
||||
document = replacement
|
||||
}
|
||||
finalFileName = actualFileName
|
||||
}
|
||||
context.contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
|
||||
srcFile.inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
syncOutputStream(output)
|
||||
} ?: throw IllegalStateException("failed to open SAF output stream")
|
||||
srcFile.delete()
|
||||
} catch (e: Exception) {
|
||||
@@ -218,19 +156,9 @@ object SafDownloadHandler {
|
||||
}
|
||||
respObj.put("file_path", document.uri.toString())
|
||||
respObj.put("file_name", document.name ?: fileName)
|
||||
if (finalizerPromotesStaged) {
|
||||
if (useStagedOutput) {
|
||||
respObj.put("saf_staged_output", true)
|
||||
respObj.put("saf_staged_file_name", document.name ?: stagedFileName)
|
||||
} else if (useStagedOutput) {
|
||||
// Legacy caller (foreground Dart queue): publish here by
|
||||
// renaming the staged file to its final name.
|
||||
val published = replaceFinalDocument(targetDir, document, finalFileName)
|
||||
if (published == null) {
|
||||
document.delete()
|
||||
return errorJson("Failed to publish SAF download")
|
||||
}
|
||||
respObj.put("file_path", published.uri.toString())
|
||||
respObj.put("file_name", published.name ?: finalFileName)
|
||||
}
|
||||
} else {
|
||||
document.delete()
|
||||
@@ -249,43 +177,6 @@ object SafDownloadHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps [document] into place under [finalName] without a window where the
|
||||
* previous file is gone but the new one is not yet in place. Any existing
|
||||
* file is renamed aside first and only deleted once the staged file holds
|
||||
* the final name; a failed swap restores it. Returns the published
|
||||
* document, or null when the swap failed (the caller owns [document]).
|
||||
*/
|
||||
private fun replaceFinalDocument(
|
||||
targetDir: DocumentFile,
|
||||
document: DocumentFile,
|
||||
finalName: String
|
||||
): DocumentFile? {
|
||||
val existingFinal = targetDir.findFile(finalName)
|
||||
var aside: DocumentFile? = null
|
||||
if (existingFinal != null && existingFinal.uri != document.uri) {
|
||||
val asideName = buildReplacedSafFileName(finalName)
|
||||
try {
|
||||
targetDir.findFile(asideName)?.delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
if (!existingFinal.renameTo(asideName)) {
|
||||
return null
|
||||
}
|
||||
aside = targetDir.findFile(asideName) ?: existingFinal
|
||||
}
|
||||
if (!document.renameTo(finalName)) {
|
||||
aside?.renameTo(finalName)
|
||||
return null
|
||||
}
|
||||
aside?.delete()
|
||||
return targetDir.findFile(finalName) ?: document
|
||||
}
|
||||
|
||||
private fun buildReplacedSafFileName(fileName: String): String {
|
||||
return "${sanitizeFilename(fileName)}.replaced"
|
||||
}
|
||||
|
||||
fun copyContentUriToTemp(context: Context, uriStr: String): String? {
|
||||
return try {
|
||||
val uri = Uri.parse(uriStr)
|
||||
@@ -315,124 +206,12 @@ object SafDownloadHandler {
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
srcPath: String
|
||||
): String? {
|
||||
val finalName = sanitizeFilename(fileName)
|
||||
return withSafNameLock(treeUriStr, sanitizeRelativeDir(relativeDir), finalName) {
|
||||
writeFileToSafLocked(context, treeUriStr, relativeDir, finalName, srcPath)
|
||||
}
|
||||
}
|
||||
|
||||
fun writeFileToSafUnique(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
srcPath: String,
|
||||
preservedSuffix: String = "",
|
||||
): UniqueWriteResult? {
|
||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
||||
val preferredName = sanitizeFilenamePreservingSuffix(fileName, preservedSuffix)
|
||||
return withSafNameLock(treeUriStr, safeRelativeDir, preferredName) {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = ensureDocumentDir(context, treeUri, safeRelativeDir) ?: return@withSafNameLock null
|
||||
val availableName = findAvailableFileName(
|
||||
targetDir,
|
||||
preferredName,
|
||||
preservedSuffix,
|
||||
)
|
||||
val uri = writeFileToSafLocked(
|
||||
context,
|
||||
treeUriStr,
|
||||
safeRelativeDir,
|
||||
availableName,
|
||||
srcPath,
|
||||
) ?: return@withSafNameLock null
|
||||
UniqueWriteResult(uri = uri, fileName = availableName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitizeFilenamePreservingSuffix(fileName: String, suffix: String): String {
|
||||
val sanitized = sanitizeFilename(fileName)
|
||||
val trimmedSuffix = suffix.trim()
|
||||
if (trimmedSuffix.isEmpty() || sanitized.contains(trimmedSuffix)) return sanitized
|
||||
|
||||
val dotIndex = fileName.lastIndexOf('.')
|
||||
val hasExtension = dotIndex > 0 && dotIndex < fileName.length - 1
|
||||
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
|
||||
val rawStem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
|
||||
val rawPrefix = rawStem.replace(trimmedSuffix, "").trim(' ', '_', '-')
|
||||
val safeSuffix = sanitizeFilename(trimmedSuffix)
|
||||
val reserved = " - $safeSuffix$extension"
|
||||
val prefixBytes = (MAX_SAF_DISPLAY_NAME_UTF8_BYTES - reserved.toByteArray(Charsets.UTF_8).size)
|
||||
.coerceAtLeast(1)
|
||||
val safePrefix = truncateUtf8Bytes(sanitizeFilename(rawPrefix), prefixBytes)
|
||||
.trim()
|
||||
.trim('.', ' ', '_', '-')
|
||||
.ifBlank { "track" }
|
||||
return "$safePrefix$reserved"
|
||||
}
|
||||
|
||||
private fun findAvailableFileName(
|
||||
parent: DocumentFile,
|
||||
preferredName: String,
|
||||
preservedSuffix: String,
|
||||
): String {
|
||||
if (parent.findFile(preferredName) == null) return preferredName
|
||||
for (counter in 2..9999) {
|
||||
val candidate = appendFilenameCounter(
|
||||
preferredName,
|
||||
counter.toLong(),
|
||||
preservedSuffix,
|
||||
)
|
||||
if (parent.findFile(candidate) == null) return candidate
|
||||
}
|
||||
return appendFilenameCounter(
|
||||
preferredName,
|
||||
System.currentTimeMillis(),
|
||||
preservedSuffix,
|
||||
)
|
||||
}
|
||||
|
||||
private fun appendFilenameCounter(
|
||||
fileName: String,
|
||||
counter: Long,
|
||||
preservedSuffix: String,
|
||||
): String {
|
||||
val dotIndex = fileName.lastIndexOf('.')
|
||||
val hasExtension = dotIndex > 0 && dotIndex < fileName.length - 1
|
||||
val extension = if (hasExtension) fileName.substring(dotIndex) else ""
|
||||
val originalStem = if (hasExtension) fileName.substring(0, dotIndex) else fileName
|
||||
val safePreservedSuffix = preservedSuffix.trim()
|
||||
val hasPreservedSuffix = safePreservedSuffix.isNotEmpty() && originalStem.contains(safePreservedSuffix)
|
||||
val stem = if (hasPreservedSuffix) {
|
||||
originalStem.replace(safePreservedSuffix, "").trim(' ', '_', '-')
|
||||
} else {
|
||||
originalStem
|
||||
}
|
||||
val suffix = if (hasPreservedSuffix) {
|
||||
" - $safePreservedSuffix ($counter)"
|
||||
} else {
|
||||
" ($counter)"
|
||||
}
|
||||
val reservedBytes = extension.toByteArray(Charsets.UTF_8).size +
|
||||
suffix.toByteArray(Charsets.UTF_8).size
|
||||
val maxStemBytes = (MAX_SAF_DISPLAY_NAME_UTF8_BYTES - reservedBytes).coerceAtLeast(1)
|
||||
val safeStem = truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').ifBlank { "track" }
|
||||
return "$safeStem$suffix$extension"
|
||||
}
|
||||
|
||||
private fun writeFileToSafLocked(
|
||||
context: Context,
|
||||
treeUriStr: String,
|
||||
relativeDir: String,
|
||||
finalName: String,
|
||||
srcPath: String
|
||||
): String? {
|
||||
var stagedDocument: DocumentFile? = null
|
||||
return try {
|
||||
val treeUri = Uri.parse(treeUriStr)
|
||||
val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null
|
||||
val finalName = sanitizeFilename(fileName)
|
||||
val ext = normalizeExt(finalName.substringAfterLast('.', ""))
|
||||
val stagedName = buildStagedSafFileName(finalName)
|
||||
deleteStaleStagedFiles(targetDir, finalName, ext)
|
||||
@@ -449,16 +228,18 @@ object SafDownloadHandler {
|
||||
File(srcPath).inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
syncOutputStream(output)
|
||||
}
|
||||
|
||||
val published = replaceFinalDocument(targetDir, document, finalName)
|
||||
if (published == null) {
|
||||
val existingFinal = targetDir.findFile(finalName)
|
||||
if (existingFinal != null && existingFinal.uri != document.uri) {
|
||||
existingFinal.delete()
|
||||
}
|
||||
if (!document.renameTo(finalName)) {
|
||||
document.delete()
|
||||
return null
|
||||
}
|
||||
stagedDocument = null
|
||||
published.uri.toString()
|
||||
targetDir.findFile(finalName)?.uri?.toString() ?: document.uri.toString()
|
||||
} catch (e: Exception) {
|
||||
stagedDocument?.delete()
|
||||
android.util.Log.w("SpotiFLAC", "Failed to write file to SAF: ${e.message}")
|
||||
@@ -474,20 +255,21 @@ object SafDownloadHandler {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun normalizeExt(ext: String?): String {
|
||||
val trimmed = ext?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return ""
|
||||
return if (trimmed.startsWith(".")) trimmed.lowercase(Locale.ROOT) else ".${trimmed.lowercase(Locale.ROOT)}"
|
||||
private fun normalizeExt(ext: String?): String {
|
||||
if (ext.isNullOrBlank()) return ""
|
||||
return if (ext.startsWith(".")) {
|
||||
ext.lowercase(Locale.ROOT)
|
||||
} else {
|
||||
".${ext.lowercase(Locale.ROOT)}"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun mimeTypeForExt(ext: String?): String {
|
||||
private fun mimeTypeForExt(ext: String?): String {
|
||||
return when (normalizeExt(ext)) {
|
||||
".m4a", ".mp4" -> "audio/mp4"
|
||||
".mp3" -> "audio/mpeg"
|
||||
".opus", ".ogg" -> "audio/ogg"
|
||||
".opus" -> "audio/ogg"
|
||||
".flac" -> "audio/flac"
|
||||
".wav" -> "audio/wav"
|
||||
".aiff", ".aif", ".aifc" -> "audio/aiff"
|
||||
".lrc" -> "application/octet-stream"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
@@ -531,8 +313,7 @@ object SafDownloadHandler {
|
||||
private fun deleteStaleStagedFiles(parent: DocumentFile, fileName: String, outputExt: String) {
|
||||
val stagedNames = linkedSetOf(
|
||||
buildStagedSafFileName(fileName),
|
||||
buildLegacyStagedSafFileName(fileName, outputExt),
|
||||
buildReplacedSafFileName(fileName)
|
||||
buildLegacyStagedSafFileName(fileName, outputExt)
|
||||
)
|
||||
for (stagedName in stagedNames) {
|
||||
try {
|
||||
@@ -542,7 +323,7 @@ object SafDownloadHandler {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun sanitizeFilename(name: String): String {
|
||||
private fun sanitizeFilename(name: String): String {
|
||||
var sanitized = name
|
||||
.replace("/", " ")
|
||||
.replace(Regex("[\\\\:*?\"<>|]"), " ")
|
||||
@@ -601,7 +382,7 @@ object SafDownloadHandler {
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
internal fun sanitizeRelativeDir(relativeDir: String): String {
|
||||
private fun sanitizeRelativeDir(relativeDir: String): String {
|
||||
if (relativeDir.isBlank()) return ""
|
||||
return relativeDir
|
||||
.split("/")
|
||||
@@ -610,7 +391,7 @@ object SafDownloadHandler {
|
||||
.joinToString("/")
|
||||
}
|
||||
|
||||
internal fun ensureDocumentDir(
|
||||
private fun ensureDocumentDir(
|
||||
context: Context,
|
||||
treeUri: Uri,
|
||||
relativeDir: String
|
||||
@@ -642,7 +423,7 @@ object SafDownloadHandler {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun findDocumentDir(
|
||||
private fun findDocumentDir(
|
||||
context: Context,
|
||||
treeUri: Uri,
|
||||
relativeDir: String
|
||||
@@ -660,7 +441,7 @@ object SafDownloadHandler {
|
||||
return current
|
||||
}
|
||||
|
||||
internal fun createOrReuseDocumentFile(
|
||||
private fun createOrReuseDocumentFile(
|
||||
parent: DocumentFile,
|
||||
mimeType: String,
|
||||
fileName: String
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<automotiveApp>
|
||||
<uses name="media" />
|
||||
</automotiveApp>
|
||||
@@ -1,30 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<!--
|
||||
SpotiFLAC Mobile provides an explicit, schema-aware Backup & Restore
|
||||
feature. Platform backup must not copy device-bound SAF grants, paths,
|
||||
queues, extension credentials, or signed sessions to another install.
|
||||
-->
|
||||
<cloud-backup>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
<exclude domain="device_root" path="." />
|
||||
<exclude domain="device_file" path="." />
|
||||
<exclude domain="device_database" path="." />
|
||||
<exclude domain="device_sharedpref" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
<exclude domain="device_root" path="." />
|
||||
<exclude domain="device_file" path="." />
|
||||
<exclude domain="device_database" path="." />
|
||||
<exclude domain="device_sharedpref" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<!-- Android 11 and lower use this legacy rule format. -->
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
<exclude domain="device_root" path="." />
|
||||
<exclude domain="device_file" path="." />
|
||||
<exclude domain="device_database" path="." />
|
||||
<exclude domain="device_sharedpref" path="." />
|
||||
</full-backup-content>
|
||||
@@ -1,152 +0,0 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NativeWorkerPolicyTest {
|
||||
@Test
|
||||
fun rateLimitRetriesExactlyOnce() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldRetryRateLimit(
|
||||
errorType = "rate_limit",
|
||||
errorMessage = "Too many requests",
|
||||
attempts = 0,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldRetryRateLimit(
|
||||
errorType = "rate_limit",
|
||||
errorMessage = "Too many requests",
|
||||
attempts = 1,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rateLimitDetectionFallsBackToMessage() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldRetryRateLimit(
|
||||
errorType = "network",
|
||||
errorMessage = "HTTP 429: retry later",
|
||||
attempts = 0,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldRetryRateLimit(
|
||||
errorType = "network",
|
||||
errorMessage = "Connection reset",
|
||||
attempts = 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retryDelayUsesServerHintAndSafeBounds() {
|
||||
assertEquals(
|
||||
45,
|
||||
NativeWorkerPolicy.rateLimitDelaySeconds(
|
||||
retryAfterSeconds = 45,
|
||||
errorMessage = null,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
12,
|
||||
NativeWorkerPolicy.rateLimitDelaySeconds(
|
||||
retryAfterSeconds = null,
|
||||
errorMessage = "Retry-After seconds: 12",
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
5,
|
||||
NativeWorkerPolicy.rateLimitDelaySeconds(
|
||||
retryAfterSeconds = 1,
|
||||
errorMessage = null,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
300,
|
||||
NativeWorkerPolicy.rateLimitDelaySeconds(
|
||||
retryAfterSeconds = 900,
|
||||
errorMessage = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wifiOnlyPausesWithoutWifi() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldPauseForNetwork(
|
||||
downloadNetworkMode = "wifi_only",
|
||||
hasWifi = false,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldPauseForNetwork(
|
||||
downloadNetworkMode = "wifi_only",
|
||||
hasWifi = true,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldPauseForNetwork(
|
||||
downloadNetworkMode = "any",
|
||||
hasWifi = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verificationDetectionUsesTypeAndMessageFallback() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.isVerificationRequired(
|
||||
errorType = "verification_required",
|
||||
errorMessage = null,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.isVerificationRequired(
|
||||
errorType = "unknown",
|
||||
errorMessage = "Challenge required before download",
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.isVerificationRequired(
|
||||
errorType = "network",
|
||||
errorMessage = "Connection reset",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completionAlertSkipsCancelledOrEmptyRuns() {
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldNotifyQueueComplete(
|
||||
cancelRequested = false,
|
||||
completed = 1,
|
||||
failed = 0,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
NativeWorkerPolicy.shouldNotifyQueueComplete(
|
||||
cancelRequested = false,
|
||||
completed = 0,
|
||||
failed = 1,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldNotifyQueueComplete(
|
||||
cancelRequested = true,
|
||||
completed = 1,
|
||||
failed = 0,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
NativeWorkerPolicy.shouldNotifyQueueComplete(
|
||||
cancelRequested = false,
|
||||
completed = 0,
|
||||
failed = 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ subprojects {
|
||||
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_25
|
||||
targetCompatibility = JavaVersion.VERSION_25
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
// Enable multidex for all subprojects
|
||||
@@ -27,7 +27,7 @@ subprojects {
|
||||
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-all.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
|
||||
@@ -19,8 +19,8 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "9.3.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.4.10" apply false
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"name": "SpotiFLAC Mobile",
|
||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||
"developerName": "zarzet",
|
||||
"version": "4.7.1",
|
||||
"versionDate": "2026-07-01",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.7.1/SpotiFLAC-v4.7.1-ios-unsigned.ipa",
|
||||
"version": "4.6.0",
|
||||
"versionDate": "2026-06-13",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.6.0/SpotiFLAC-v4.6.0-ios-unsigned.ipa",
|
||||
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||
"size": 37455821
|
||||
"size": 34347687
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// mp4Box is a minimal ISO-BMFF / QuickTime box view over an in-memory buffer.
|
||||
type mp4Box struct {
|
||||
offset int64
|
||||
size int64
|
||||
hdr int64
|
||||
typ string
|
||||
}
|
||||
|
||||
func (b mp4Box) body() int64 { return b.offset + b.hdr }
|
||||
func (b mp4Box) end() int64 { return b.offset + b.size }
|
||||
|
||||
func readMP4Box(data []byte, pos int64) (mp4Box, bool) {
|
||||
n := int64(len(data))
|
||||
if pos < 0 || pos+8 > n {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
size := int64(binary.BigEndian.Uint32(data[pos : pos+4]))
|
||||
typ := string(data[pos+4 : pos+8])
|
||||
hdr := int64(8)
|
||||
switch size {
|
||||
case 1:
|
||||
if pos+16 > n {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
size = int64(binary.BigEndian.Uint64(data[pos+8 : pos+16]))
|
||||
hdr = 16
|
||||
case 0:
|
||||
size = n - pos
|
||||
}
|
||||
if size < hdr || pos+size > n {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
return mp4Box{offset: pos, size: size, hdr: hdr, typ: typ}, true
|
||||
}
|
||||
|
||||
func findChildMP4(data []byte, start, end int64, typ string) (mp4Box, bool) {
|
||||
pos := start
|
||||
for pos+8 <= end {
|
||||
b, ok := readMP4Box(data, pos)
|
||||
if !ok {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
if b.typ == typ {
|
||||
return b, true
|
||||
}
|
||||
pos = b.end()
|
||||
}
|
||||
return mp4Box{}, false
|
||||
}
|
||||
|
||||
func eachChildMP4(data []byte, start, end int64, typ string, fn func(mp4Box) bool) {
|
||||
pos := start
|
||||
for pos+8 <= end {
|
||||
b, ok := readMP4Box(data, pos)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if b.typ == typ && !fn(b) {
|
||||
return
|
||||
}
|
||||
pos = b.end()
|
||||
}
|
||||
}
|
||||
|
||||
// findBoxBySignature scans [start,end) for a box of the given type, matching the
|
||||
// 4-byte type tag and validating the preceding size field. Used to locate dac4
|
||||
// which may be nested inside an encrypted (enca) sample entry.
|
||||
func findBoxBySignature(data []byte, start, end int64, typ string) (mp4Box, bool) {
|
||||
if len(typ) != 4 {
|
||||
return mp4Box{}, false
|
||||
}
|
||||
for i := start; i+8 <= end; i++ {
|
||||
if data[i+4] == typ[0] && data[i+5] == typ[1] && data[i+6] == typ[2] && data[i+7] == typ[3] {
|
||||
if b, ok := readMP4Box(data, i); ok && b.typ == typ {
|
||||
return b, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return mp4Box{}, false
|
||||
}
|
||||
|
||||
// audioSampleEntryHeaderLen returns the byte length of the fixed audio sample
|
||||
// entry header (from the box body start) before child boxes begin. ok is false
|
||||
// for malformed/truncated entries whose declared header is not fully present.
|
||||
func audioSampleEntryHeaderLen(data []byte, entry mp4Box) (hdrLen int64, ok bool) {
|
||||
// 6 bytes reserved + 2 bytes data_reference_index, then the audio fields.
|
||||
base := entry.body()
|
||||
if base+10 > entry.end() {
|
||||
return 0, false
|
||||
}
|
||||
version := binary.BigEndian.Uint16(data[base+8 : base+10])
|
||||
hdrLen = 8 + 20
|
||||
switch version {
|
||||
case 1:
|
||||
hdrLen += 16
|
||||
case 2:
|
||||
hdrLen += 36
|
||||
}
|
||||
if base+hdrLen > entry.end() {
|
||||
return 0, false
|
||||
}
|
||||
return hdrLen, true
|
||||
}
|
||||
|
||||
type ac4Location struct {
|
||||
chain []mp4Box // moov, trak, mdia, minf, stbl, stsd (ancestors to grow)
|
||||
entry mp4Box // the ac-4 sample entry
|
||||
}
|
||||
|
||||
func locateAC4Entry(data []byte) (ac4Location, bool) {
|
||||
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
|
||||
if !ok {
|
||||
return ac4Location{}, false
|
||||
}
|
||||
var found ac4Location
|
||||
var ok2 bool
|
||||
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
|
||||
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
stsd, ok := findChildMP4(data, stbl.body(), stbl.end(), "stsd")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
entry, ok := findChildMP4(data, stsd.body()+8, stsd.end(), "ac-4")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
found = ac4Location{chain: []mp4Box{moov, trak, mdia, minf, stbl, stsd}, entry: entry}
|
||||
ok2 = true
|
||||
return false
|
||||
})
|
||||
return found, ok2
|
||||
}
|
||||
|
||||
func growBoxSize(data []byte, b mp4Box, delta int64) {
|
||||
if b.hdr == 16 {
|
||||
binary.BigEndian.PutUint64(data[b.offset+8:b.offset+16], uint64(b.size+delta))
|
||||
} else {
|
||||
binary.BigEndian.PutUint32(data[b.offset:b.offset+4], uint32(b.size+delta))
|
||||
}
|
||||
}
|
||||
|
||||
// shiftChunkOffsets adds delta to every stco/co64 entry that references a file
|
||||
// offset at or beyond insertPos, keeping sample pointers valid after bytes are
|
||||
// inserted into moov.
|
||||
func shiftChunkOffsets(data []byte, moov mp4Box, insertPos, delta int64) {
|
||||
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
|
||||
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if stco, ok := findChildMP4(data, stbl.body(), stbl.end(), "stco"); ok {
|
||||
base := stco.body() + 4
|
||||
if base+4 <= stco.end() {
|
||||
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
|
||||
p := base + 4
|
||||
for i := int64(0); i < count && p+4 <= stco.end(); i++ {
|
||||
v := int64(binary.BigEndian.Uint32(data[p : p+4]))
|
||||
if v >= insertPos {
|
||||
binary.BigEndian.PutUint32(data[p:p+4], uint32(v+delta))
|
||||
}
|
||||
p += 4
|
||||
}
|
||||
}
|
||||
}
|
||||
if co64, ok := findChildMP4(data, stbl.body(), stbl.end(), "co64"); ok {
|
||||
base := co64.body() + 4
|
||||
if base+4 <= co64.end() {
|
||||
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
|
||||
p := base + 4
|
||||
for i := int64(0); i < count && p+8 <= co64.end(); i++ {
|
||||
v := int64(binary.BigEndian.Uint64(data[p : p+8]))
|
||||
if v >= insertPos {
|
||||
binary.BigEndian.PutUint64(data[p:p+8], uint64(v+delta))
|
||||
}
|
||||
p += 8
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeQuickTimeBrandsInBuf rewrites a "qt " ftyp brand to isom/mp42 in
|
||||
// place. Works on any buffer whose top level contains the ftyp box (whole file
|
||||
// or the ftyp box alone). Returns whether anything changed.
|
||||
func normalizeQuickTimeBrandsInBuf(data []byte) bool {
|
||||
ftyp, ok := findChildMP4(data, 0, int64(len(data)), "ftyp")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
if ftyp.body()+4 <= int64(len(data)) && string(data[ftyp.body():ftyp.body()+4]) != "mp42" {
|
||||
copy(data[ftyp.body():ftyp.body()+4], []byte("mp42"))
|
||||
changed = true
|
||||
}
|
||||
for p := ftyp.body() + 8; p+4 <= ftyp.end(); p += 4 {
|
||||
if string(data[p:p+4]) == "qt " {
|
||||
copy(data[p:p+4], []byte("isom"))
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// normalizeQuickTimeAudioEntry rewrites a version-1 QuickTime sound sample
|
||||
// entry into a plain version-0 AudioSampleEntry, dropping the 16-byte v1
|
||||
// extension. base is the buffer's absolute file offset (0 for a whole-file
|
||||
// buffer, the moov offset for a moov-only buffer).
|
||||
func normalizeQuickTimeAudioEntry(data []byte, base int64) []byte {
|
||||
loc, ok := locateAC4Entry(data)
|
||||
if !ok {
|
||||
return data
|
||||
}
|
||||
entry := loc.entry
|
||||
verPos := entry.body() + 8
|
||||
if verPos+2 > entry.end() {
|
||||
return data
|
||||
}
|
||||
if binary.BigEndian.Uint16(data[verPos:verPos+2]) != 1 {
|
||||
return data // already v0 (or v2, left untouched)
|
||||
}
|
||||
|
||||
// The v1 QuickTime sound extension is the 16 bytes following the 20-byte v0
|
||||
// audio fields (samplesPerPacket, bytesPerPacket, bytesPerFrame, bytesPerSample).
|
||||
extStart := entry.body() + 8 + 20
|
||||
extEnd := extStart + 16
|
||||
if extEnd > entry.end() {
|
||||
return data
|
||||
}
|
||||
delta := int64(-16)
|
||||
|
||||
binary.BigEndian.PutUint16(data[verPos:verPos+2], 0)
|
||||
shiftChunkOffsets(data, loc.chain[0], base+extStart, delta)
|
||||
for _, b := range loc.chain {
|
||||
growBoxSize(data, b, delta)
|
||||
}
|
||||
growBoxSize(data, entry, delta)
|
||||
|
||||
out := make([]byte, 0, len(data)-16)
|
||||
out = append(out, data[:extStart]...)
|
||||
out = append(out, data[extEnd:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeQuickTimeAudioToMP4 rewrites a QuickTime-flavored file (FFmpeg mov
|
||||
// muxer output: ftyp brand "qt " and a version-1 sound sample entry) into a
|
||||
// standard ISO MP4: an isom/mp42 brand and a plain version-0 AudioSampleEntry.
|
||||
// Windows Media Foundation (and other strict parsers) reject the QuickTime
|
||||
// flavor for AC-4 even when dac4 is present.
|
||||
func normalizeQuickTimeAudioToMP4(data []byte) []byte {
|
||||
normalizeQuickTimeBrandsInBuf(data)
|
||||
return normalizeQuickTimeAudioEntry(data, 0)
|
||||
}
|
||||
|
||||
// EnsureAC4ConfigBox makes a decrypted AC-4 MP4 standards-compliant and
|
||||
// playable: it normalizes FFmpeg's QuickTime-flavored mov output to an ISO MP4
|
||||
// and injects the AC-4 configuration box (dac4) into the ac-4 sample entry. The
|
||||
// dac4 box is copied verbatim from sourcePath (the original MP4, whose plaintext
|
||||
// moov still carries it). No-op when the file has no AC-4 track. Only the ftyp
|
||||
// and moov boxes are held in memory; the audio bulk is streamed.
|
||||
func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error {
|
||||
f, err := os.Open(decryptedPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
// A non-MP4 decrypt output (e.g. a raw FLAC stream) is not an AC-4 file;
|
||||
// bail out before the box parser reports it as a corrupt MP4. A real
|
||||
// ISO-BMFF box type is four printable ASCII bytes.
|
||||
var head [8]byte
|
||||
if _, err := f.ReadAt(head[:], 0); err != nil {
|
||||
f.Close()
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, c := range head[4:8] {
|
||||
if c < 0x20 || c > 0x7e {
|
||||
f.Close()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
moovBuf, moovOffset, moovFound, err := loadTopLevelMP4Box(f, info.Size(), "moov")
|
||||
if err != nil || !moovFound {
|
||||
f.Close()
|
||||
return err // parse/read failure, or no moov: nothing to do
|
||||
}
|
||||
ftypBuf, ftypOffset, ftypFound, err := loadTopLevelMP4Box(f, info.Size(), "ftyp")
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
moovLen := int64(len(moovBuf))
|
||||
|
||||
if _, ok := locateAC4Entry(moovBuf); !ok {
|
||||
return nil // not an AC-4 file; nothing to do
|
||||
}
|
||||
|
||||
ftypChanged := ftypFound && normalizeQuickTimeBrandsInBuf(ftypBuf)
|
||||
dst := normalizeQuickTimeAudioEntry(moovBuf, moovOffset)
|
||||
|
||||
loc, ok := locateAC4Entry(dst)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
hdrLen, ok := audioSampleEntryHeaderLen(dst, loc.entry)
|
||||
if !ok {
|
||||
return fmt.Errorf("malformed ac-4 sample entry")
|
||||
}
|
||||
childStart := loc.entry.body() + hdrLen
|
||||
if _, has := findChildMP4(dst, childStart, loc.entry.end(), "dac4"); has {
|
||||
// Already has dac4; still persist any normalization changes.
|
||||
return writeAC4Sections(decryptedPath, ftypChanged, ftypBuf, ftypOffset, dst, moovOffset, moovLen)
|
||||
}
|
||||
|
||||
srcF, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srcInfo, err := srcF.Stat()
|
||||
if err != nil {
|
||||
srcF.Close()
|
||||
return err
|
||||
}
|
||||
srcMoovBuf, _, srcFound, err := loadTopLevelMP4Box(srcF, srcInfo.Size(), "moov")
|
||||
srcF.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !srcFound {
|
||||
return fmt.Errorf("source has no moov")
|
||||
}
|
||||
srcMoov, ok := readMP4Box(srcMoovBuf, 0)
|
||||
if !ok {
|
||||
return fmt.Errorf("source has no moov")
|
||||
}
|
||||
dac4Box, ok := findBoxBySignature(srcMoovBuf, srcMoov.body(), srcMoov.end(), "dac4")
|
||||
if !ok {
|
||||
return fmt.Errorf("dac4 not found in source")
|
||||
}
|
||||
dac4 := append([]byte{}, srcMoovBuf[dac4Box.offset:dac4Box.end()]...)
|
||||
|
||||
insertPos := childStart
|
||||
delta := int64(len(dac4))
|
||||
|
||||
shiftChunkOffsets(dst, loc.chain[0], moovOffset+insertPos, delta)
|
||||
for _, b := range loc.chain {
|
||||
growBoxSize(dst, b, delta)
|
||||
}
|
||||
growBoxSize(dst, loc.entry, delta)
|
||||
|
||||
out := make([]byte, 0, len(dst)+len(dac4))
|
||||
out = append(out, dst[:insertPos]...)
|
||||
out = append(out, dac4...)
|
||||
out = append(out, dst[insertPos:]...)
|
||||
|
||||
return writeAC4Sections(decryptedPath, ftypChanged, ftypBuf, ftypOffset, out, moovOffset, moovLen)
|
||||
}
|
||||
|
||||
// writeAC4Sections streams the edited moov (and, when changed, ftyp) back into
|
||||
// the file.
|
||||
func writeAC4Sections(path string, ftypChanged bool, ftypBuf []byte, ftypOffset int64, moovBuf []byte, moovOffset, origMoovLen int64) error {
|
||||
sections := []fileSection{
|
||||
{start: moovOffset, end: moovOffset + origMoovLen, data: moovBuf},
|
||||
}
|
||||
if ftypChanged {
|
||||
sections = append(sections, fileSection{
|
||||
start: ftypOffset,
|
||||
end: ftypOffset + int64(len(ftypBuf)),
|
||||
data: ftypBuf,
|
||||
})
|
||||
}
|
||||
return replaceFileSectionsStreaming(path, sections)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func mp4TestBox(typ string, body []byte) []byte {
|
||||
out := make([]byte, 8+len(body))
|
||||
binary.BigEndian.PutUint32(out[:4], uint32(len(out)))
|
||||
copy(out[4:8], typ)
|
||||
copy(out[8:], body)
|
||||
return out
|
||||
}
|
||||
|
||||
func mp4TestAC4Tree(entryBody []byte) []byte {
|
||||
entry := mp4TestBox("ac-4", entryBody)
|
||||
stsdBody := append([]byte{
|
||||
0, 0, 0, 0, // version/flags
|
||||
0, 0, 0, 1, // entry_count
|
||||
}, entry...)
|
||||
stsd := mp4TestBox("stsd", stsdBody)
|
||||
stbl := mp4TestBox("stbl", stsd)
|
||||
minf := mp4TestBox("minf", stbl)
|
||||
mdia := mp4TestBox("mdia", minf)
|
||||
trak := mp4TestBox("trak", mdia)
|
||||
moov := mp4TestBox("moov", trak)
|
||||
return moov
|
||||
}
|
||||
|
||||
func shortAC4SampleEntryBody(version uint16) []byte {
|
||||
body := make([]byte, 10)
|
||||
binary.BigEndian.PutUint16(body[8:10], version)
|
||||
return body
|
||||
}
|
||||
|
||||
func TestNormalizeQuickTimeAudioToMP4IgnoresTruncatedAC4Entry(t *testing.T) {
|
||||
input := mp4TestAC4Tree(shortAC4SampleEntryBody(1))
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("normalizeQuickTimeAudioToMP4 panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
got := normalizeQuickTimeAudioToMP4(append([]byte{}, input...))
|
||||
if !bytes.Equal(got, input) {
|
||||
t.Fatal("truncated QuickTime AC-4 entry should be left unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAC4ConfigBoxRejectsTruncatedAC4Entry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
decryptedPath := filepath.Join(dir, "decrypted.mp4")
|
||||
sourcePath := filepath.Join(dir, "source.mp4")
|
||||
|
||||
if err := os.WriteFile(decryptedPath, mp4TestAC4Tree(shortAC4SampleEntryBody(2)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(sourcePath, mp4TestBox("moov", mp4TestBox("dac4", []byte{1, 2, 3, 4})), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("EnsureAC4ConfigBox panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := EnsureAC4ConfigBox(decryptedPath, sourcePath); err == nil {
|
||||
t.Fatal("expected malformed AC-4 sample entry error")
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ac4Metadata mirrors the tag fields the app embeds for other formats. Numeric
|
||||
// fields are strings because they arrive as a JSON-encoded map of strings.
|
||||
type ac4Metadata struct {
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Album string `json:"album"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
Date string `json:"date"`
|
||||
Genre string `json:"genre"`
|
||||
Composer string `json:"composer"`
|
||||
TrackNumber string `json:"trackNumber"`
|
||||
TotalTracks string `json:"totalTracks"`
|
||||
DiscNumber string `json:"discNumber"`
|
||||
TotalDiscs string `json:"totalDiscs"`
|
||||
ISRC string `json:"isrc"`
|
||||
Label string `json:"label"`
|
||||
Copyright string `json:"copyright"`
|
||||
Lyrics string `json:"lyrics"`
|
||||
}
|
||||
|
||||
func atoiSafe(s string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func itunesTextTag(atomType, value string) []byte {
|
||||
data := make([]byte, 8+len(value))
|
||||
binary.BigEndian.PutUint32(data[0:4], 1) // well-known type 1 = UTF-8
|
||||
copy(data[8:], []byte(value))
|
||||
return buildM4AAtom(atomType, buildM4AAtom("data", data))
|
||||
}
|
||||
|
||||
func itunesNumberPairTag(atomType string, number, total int) []byte {
|
||||
payload := make([]byte, 8)
|
||||
binary.BigEndian.PutUint16(payload[2:4], uint16(number))
|
||||
binary.BigEndian.PutUint16(payload[4:6], uint16(total))
|
||||
data := make([]byte, 8+len(payload))
|
||||
binary.BigEndian.PutUint32(data[0:4], 0) // type 0 = implicit/binary
|
||||
copy(data[8:], payload)
|
||||
return buildM4AAtom(atomType, buildM4AAtom("data", data))
|
||||
}
|
||||
|
||||
func itunesCoverTag(image []byte) []byte {
|
||||
typeCode := uint32(13) // JPEG
|
||||
if len(image) >= 8 &&
|
||||
image[0] == 0x89 && image[1] == 0x50 && image[2] == 0x4E && image[3] == 0x47 {
|
||||
typeCode = 14 // PNG
|
||||
}
|
||||
data := make([]byte, 8+len(image))
|
||||
binary.BigEndian.PutUint32(data[0:4], typeCode)
|
||||
copy(data[8:], image)
|
||||
return buildM4AAtom("covr", buildM4AAtom("data", data))
|
||||
}
|
||||
|
||||
func itunesMetadataHandler() []byte {
|
||||
payload := make([]byte, 0, 25)
|
||||
payload = append(payload, 0, 0, 0, 0) // version + flags
|
||||
payload = append(payload, 0, 0, 0, 0) // pre_defined
|
||||
payload = append(payload, []byte("mdir")...) // handler type
|
||||
payload = append(payload, []byte("appl")...) // reserved[0]
|
||||
payload = append(payload, 0, 0, 0, 0, 0, 0, 0, 0) // reserved[1..2]
|
||||
payload = append(payload, 0) // empty name
|
||||
return buildM4AAtom("hdlr", payload)
|
||||
}
|
||||
|
||||
// buildITunesUdta assembles a fresh udta>meta>(hdlr+ilst) box from metadata.
|
||||
func buildITunesUdta(md ac4Metadata, cover []byte) []byte {
|
||||
ilst := make([]byte, 0, 256)
|
||||
add := func(atomType, value string) {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
ilst = append(ilst, itunesTextTag(atomType, value)...)
|
||||
}
|
||||
}
|
||||
add("\xa9nam", md.Title)
|
||||
add("\xa9ART", md.Artist)
|
||||
add("\xa9alb", md.Album)
|
||||
add("aART", md.AlbumArtist)
|
||||
add("\xa9day", md.Date)
|
||||
add("\xa9gen", md.Genre)
|
||||
add("\xa9wrt", md.Composer)
|
||||
if tn := atoiSafe(md.TrackNumber); tn > 0 {
|
||||
ilst = append(ilst, itunesNumberPairTag("trkn", tn, atoiSafe(md.TotalTracks))...)
|
||||
}
|
||||
if dn := atoiSafe(md.DiscNumber); dn > 0 {
|
||||
ilst = append(ilst, itunesNumberPairTag("disk", dn, atoiSafe(md.TotalDiscs))...)
|
||||
}
|
||||
if strings.TrimSpace(md.ISRC) != "" {
|
||||
ilst = append(ilst, buildM4AFreeformAtom("ISRC", strings.TrimSpace(md.ISRC))...)
|
||||
}
|
||||
if strings.TrimSpace(md.Label) != "" {
|
||||
ilst = append(ilst, buildM4AFreeformAtom("LABEL", strings.TrimSpace(md.Label))...)
|
||||
}
|
||||
if strings.TrimSpace(md.Copyright) != "" {
|
||||
add("cprt", md.Copyright)
|
||||
}
|
||||
if strings.TrimSpace(md.Lyrics) != "" {
|
||||
add("\xa9lyr", md.Lyrics)
|
||||
}
|
||||
if len(cover) > 0 {
|
||||
ilst = append(ilst, itunesCoverTag(cover)...)
|
||||
}
|
||||
|
||||
ilstBox := buildM4AAtom("ilst", ilst)
|
||||
metaPayload := append([]byte{0, 0, 0, 0}, itunesMetadataHandler()...)
|
||||
metaPayload = append(metaPayload, ilstBox...)
|
||||
meta := buildM4AAtom("meta", metaPayload)
|
||||
return buildM4AAtom("udta", meta)
|
||||
}
|
||||
|
||||
// writeMP4iTunesMetadata replaces (or inserts) a udta>meta>ilst metadata box in
|
||||
// the moov of an MP4 buffer and returns the rewritten bytes. base is the
|
||||
// buffer's absolute file offset (0 for a whole-file buffer) so stco/co64
|
||||
// shifts compare against the absolute positions the entries hold.
|
||||
func writeMP4iTunesMetadata(data []byte, base int64, md ac4Metadata, cover []byte) []byte {
|
||||
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
|
||||
if !ok {
|
||||
return data
|
||||
}
|
||||
newUdta := buildITunesUdta(md, cover)
|
||||
|
||||
if udta, ok := findChildMP4(data, moov.body(), moov.end(), "udta"); ok {
|
||||
delta := int64(len(newUdta)) - udta.size
|
||||
shiftChunkOffsets(data, moov, base+udta.offset, delta)
|
||||
growBoxSize(data, moov, delta)
|
||||
out := make([]byte, 0, len(data)+len(newUdta))
|
||||
out = append(out, data[:udta.offset]...)
|
||||
out = append(out, newUdta...)
|
||||
out = append(out, data[udta.end():]...)
|
||||
return out
|
||||
}
|
||||
|
||||
delta := int64(len(newUdta))
|
||||
insertPos := moov.end()
|
||||
shiftChunkOffsets(data, moov, base+insertPos, delta)
|
||||
growBoxSize(data, moov, delta)
|
||||
out := make([]byte, 0, len(data)+len(newUdta))
|
||||
out = append(out, data[:insertPos]...)
|
||||
out = append(out, newUdta...)
|
||||
out = append(out, data[insertPos:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// WriteAC4MetadataIfApplicable writes iTunes metadata into an AC-4 MP4. Returns
|
||||
// true when the file was an AC-4 track and metadata was written; false when the
|
||||
// file is not AC-4 (the caller should fall back to its normal metadata path).
|
||||
// Only the moov box is held in memory; the audio bulk is streamed.
|
||||
func WriteAC4MetadataIfApplicable(decryptedPath, metadataJSON, coverPath string) (bool, error) {
|
||||
f, err := os.Open(decryptedPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return false, err
|
||||
}
|
||||
moovBuf, moovOffset, found, err := loadTopLevelMP4Box(f, info.Size(), "moov")
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !found {
|
||||
return false, nil
|
||||
}
|
||||
moovLen := int64(len(moovBuf))
|
||||
if _, ok := locateAC4Entry(moovBuf); !ok {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
var md ac4Metadata
|
||||
if strings.TrimSpace(metadataJSON) != "" {
|
||||
_ = json.Unmarshal([]byte(metadataJSON), &md)
|
||||
}
|
||||
var cover []byte
|
||||
if strings.TrimSpace(coverPath) != "" {
|
||||
if b, err := os.ReadFile(coverPath); err == nil {
|
||||
cover = b
|
||||
}
|
||||
}
|
||||
|
||||
out := writeMP4iTunesMetadata(moovBuf, moovOffset, md, cover)
|
||||
if err := replaceFileSectionsStreaming(decryptedPath, []fileSection{
|
||||
{start: moovOffset, end: moovOffset + moovLen, data: out},
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -16,8 +16,9 @@ const (
|
||||
apeTagFlagHeader = 1 << 29 // bit 29: this is the header, not the footer
|
||||
apeTagFlagReadOnly = 1 << 0
|
||||
// Item flags: bits 1-2 encode content type
|
||||
// (00: UTF-8 text, 01: binary data, 10: external link)
|
||||
apeItemFlagBinary = 1 << 1
|
||||
apeItemFlagUTF8 = 0 << 1 // 00: UTF-8 text
|
||||
apeItemFlagBinary = 1 << 1 // 01: binary data
|
||||
apeItemFlagLink = 2 << 1 // 10: external link
|
||||
)
|
||||
|
||||
// APETagItem represents a single key-value item in an APEv2 tag.
|
||||
@@ -313,6 +314,7 @@ func marshalAPETag(tag *APETag) ([]byte, error) {
|
||||
footerFlags := uint32(1 << 31)
|
||||
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
|
||||
|
||||
// Final layout: header + items + footer
|
||||
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
|
||||
result = append(result, header...)
|
||||
result = append(result, itemsData...)
|
||||
@@ -562,7 +564,7 @@ func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
|
||||
return nil, fmt.Errorf("no APEv2 tag found")
|
||||
}
|
||||
|
||||
func parseAPETagFromFooter(r io.ReaderAt, _, footerOffset int64, footer []byte) (*APETag, error) {
|
||||
func parseAPETagFromFooter(r io.ReaderAt, fileSize, footerOffset int64, footer []byte) (*APETag, error) {
|
||||
version := binary.LittleEndian.Uint32(footer[8:12])
|
||||
tagSize := binary.LittleEndian.Uint32(footer[12:16])
|
||||
itemCount := binary.LittleEndian.Uint32(footer[16:20])
|
||||
|
||||
+149
-88
@@ -2,7 +2,6 @@ package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -186,7 +185,7 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
||||
case "TCR":
|
||||
metadata.Copyright = value
|
||||
case "ULT":
|
||||
if v := extractLangTextFrame(frameData); v != "" && metadata.Lyrics == "" {
|
||||
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
|
||||
metadata.Lyrics = v
|
||||
}
|
||||
case "TXX":
|
||||
@@ -307,11 +306,11 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
||||
case "TCOP":
|
||||
metadata.Copyright = value
|
||||
case "COMM":
|
||||
if v := extractLangTextFrame(frameData); v != "" {
|
||||
if v := extractCommentFrame(frameData); v != "" {
|
||||
metadata.Comment = v
|
||||
}
|
||||
case "USLT":
|
||||
if v := extractLangTextFrame(frameData); v != "" && metadata.Lyrics == "" {
|
||||
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
|
||||
metadata.Lyrics = v
|
||||
}
|
||||
case "TXXX":
|
||||
@@ -391,9 +390,7 @@ func extractTextFrame(data []byte) string {
|
||||
}
|
||||
}
|
||||
|
||||
// extractLangTextFrame decodes ID3 frames with an encoding byte, 3-byte
|
||||
// language code, and null-terminated descriptor before the text (COMM, USLT).
|
||||
func extractLangTextFrame(data []byte) string {
|
||||
func extractCommentFrame(data []byte) string {
|
||||
if len(data) < 5 {
|
||||
return ""
|
||||
}
|
||||
@@ -428,6 +425,42 @@ func extractLangTextFrame(data []byte) string {
|
||||
return extractTextFrame(framed)
|
||||
}
|
||||
|
||||
func extractLyricsFrame(data []byte) string {
|
||||
if len(data) < 5 {
|
||||
return ""
|
||||
}
|
||||
|
||||
encoding := data[0]
|
||||
rest := data[4:]
|
||||
|
||||
var text []byte
|
||||
switch encoding {
|
||||
case 1, 2:
|
||||
for i := 0; i+1 < len(rest); i += 2 {
|
||||
if rest[i] == 0 && rest[i+1] == 0 {
|
||||
text = rest[i+2:]
|
||||
break
|
||||
}
|
||||
}
|
||||
default:
|
||||
idx := bytes.IndexByte(rest, 0)
|
||||
if idx >= 0 && idx+1 < len(rest) {
|
||||
text = rest[idx+1:]
|
||||
} else {
|
||||
text = rest
|
||||
}
|
||||
}
|
||||
|
||||
if len(text) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
framed := make([]byte, 1+len(text))
|
||||
framed[0] = encoding
|
||||
copy(framed[1:], text)
|
||||
return extractTextFrame(framed)
|
||||
}
|
||||
|
||||
func extractUserTextFrame(data []byte) (string, string) {
|
||||
if len(data) < 2 {
|
||||
return "", ""
|
||||
@@ -548,6 +581,11 @@ func cleanGenre(genre string) string {
|
||||
return genre
|
||||
}
|
||||
|
||||
func parseTrackNumber(s string) int {
|
||||
num, _ := parseIndexPair(s)
|
||||
return num
|
||||
}
|
||||
|
||||
func parseIndexPair(s string) (int, int) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
@@ -1040,21 +1078,6 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
metadata.ReplayGainAlbumGain = value
|
||||
case "REPLAYGAIN_ALBUM_PEAK":
|
||||
metadata.ReplayGainAlbumPeak = value
|
||||
// Opus gain tags (RFC 7845): Q7.8 fixed point on the R128 -23 LUFS
|
||||
// reference. Exposed as ReplayGain 2 dB (-18 LUFS reference) so
|
||||
// consumers see one representation; explicit REPLAYGAIN_* wins.
|
||||
case "R128_TRACK_GAIN":
|
||||
if metadata.ReplayGainTrackGain == "" {
|
||||
if db, ok := r128ToReplayGainDb(value); ok {
|
||||
metadata.ReplayGainTrackGain = db
|
||||
}
|
||||
}
|
||||
case "R128_ALBUM_GAIN":
|
||||
if metadata.ReplayGainAlbumGain == "" {
|
||||
if db, ok := r128ToReplayGainDb(value); ok {
|
||||
metadata.ReplayGainAlbumGain = db
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1066,17 +1089,6 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
}
|
||||
}
|
||||
|
||||
// r128ToReplayGainDb converts an R128_*_GAIN value (integer, 1/256 dB steps,
|
||||
// -23 LUFS reference) to a ReplayGain 2 dB string (-18 LUFS reference):
|
||||
// rg = q/256 + 5. Inverse of the writer's replayGainDbToR128.
|
||||
func r128ToReplayGainDb(raw string) (string, bool) {
|
||||
q, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("%.2f dB", float64(q)/256.0+5.0), true
|
||||
}
|
||||
|
||||
func GetOggQuality(filePath string) (*OggQuality, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
@@ -1448,20 +1460,13 @@ func extractPictureFromVorbisComments(data []byte) ([]byte, string) {
|
||||
|
||||
key := "METADATA_BLOCK_PICTURE="
|
||||
if len(comment) > len(key) && strings.ToUpper(string(comment[:len(key)])) == key {
|
||||
cleaned := strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '\n', '\r', ' ', '\t':
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, string(comment[len(key):]))
|
||||
decoded, err := base64.StdEncoding.DecodeString(cleaned)
|
||||
if err != nil {
|
||||
decoded, err = base64.RawStdEncoding.DecodeString(cleaned)
|
||||
}
|
||||
b64Data := comment[len(key):]
|
||||
decoded := make([]byte, base64StdDecodeLen(len(b64Data)))
|
||||
n, err := base64StdDecode(decoded, b64Data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
decoded = decoded[:n]
|
||||
|
||||
imageData, mimeType := parseFLACPictureBlock(decoded)
|
||||
if len(imageData) > 0 {
|
||||
@@ -1515,6 +1520,71 @@ func parseFLACPictureBlock(data []byte) ([]byte, string) {
|
||||
return imageData, mimeType
|
||||
}
|
||||
|
||||
func base64StdDecodeLen(n int) int {
|
||||
return n * 6 / 8
|
||||
}
|
||||
|
||||
func base64StdDecode(dst, src []byte) (int, error) {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
|
||||
decodeMap := make([]byte, 256)
|
||||
for i := range decodeMap {
|
||||
decodeMap[i] = 0xFF
|
||||
}
|
||||
for i := 0; i < len(alphabet); i++ {
|
||||
decodeMap[alphabet[i]] = byte(i)
|
||||
}
|
||||
|
||||
si, di := 0, 0
|
||||
for si < len(src) {
|
||||
for si < len(src) && (src[si] == '\n' || src[si] == '\r' || src[si] == ' ' || src[si] == '\t') {
|
||||
si++
|
||||
}
|
||||
if si >= len(src) {
|
||||
break
|
||||
}
|
||||
|
||||
var vals [4]byte
|
||||
var valCount int
|
||||
for valCount < 4 && si < len(src) {
|
||||
c := src[si]
|
||||
si++
|
||||
if c == '=' {
|
||||
vals[valCount] = 0
|
||||
valCount++
|
||||
} else if c == '\n' || c == '\r' || c == ' ' || c == '\t' {
|
||||
continue
|
||||
} else if decodeMap[c] != 0xFF {
|
||||
vals[valCount] = decodeMap[c]
|
||||
valCount++
|
||||
}
|
||||
}
|
||||
|
||||
if valCount < 2 {
|
||||
break
|
||||
}
|
||||
|
||||
if di < len(dst) {
|
||||
dst[di] = vals[0]<<2 | vals[1]>>4
|
||||
di++
|
||||
}
|
||||
if valCount >= 3 && di < len(dst) {
|
||||
dst[di] = vals[1]<<4 | vals[2]>>2
|
||||
di++
|
||||
}
|
||||
if valCount >= 4 && di < len(dst) {
|
||||
dst[di] = vals[2]<<6 | vals[3]
|
||||
di++
|
||||
}
|
||||
}
|
||||
|
||||
return di, nil
|
||||
}
|
||||
|
||||
func extractAnyCoverArt(filePath string) ([]byte, string, error) {
|
||||
return extractAnyCoverArtWithHint(filePath, "")
|
||||
}
|
||||
|
||||
func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
if ext == "" {
|
||||
@@ -1562,6 +1632,14 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
|
||||
}
|
||||
}
|
||||
|
||||
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
|
||||
return SaveCoverToCacheWithHintAndKey(filePath, "", cacheDir, "")
|
||||
}
|
||||
|
||||
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
|
||||
return SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, "")
|
||||
}
|
||||
|
||||
func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
|
||||
explicitKey = strings.TrimSpace(explicitKey)
|
||||
if explicitKey != "" {
|
||||
@@ -1575,56 +1653,39 @@ func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
|
||||
return cacheKey
|
||||
}
|
||||
|
||||
func libraryCoverCachePaths(cacheDir, cacheKey string) (string, string) {
|
||||
hash := hashString(cacheKey)
|
||||
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
|
||||
pngPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.png", hash))
|
||||
return jpgPath, pngPath
|
||||
}
|
||||
|
||||
func existingLibraryCoverCachePath(cacheDir, cacheKey string) string {
|
||||
jpgPath, pngPath := libraryCoverCachePaths(cacheDir, cacheKey)
|
||||
|
||||
if _, err := os.Stat(jpgPath); err == nil {
|
||||
return jpgPath
|
||||
}
|
||||
if _, err := os.Stat(pngPath); err == nil {
|
||||
return pngPath
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func saveLibraryCoverDataToCache(cacheDir, cacheKey string, imageData []byte, mimeType string) (string, error) {
|
||||
if existing := existingLibraryCoverCachePath(cacheDir, cacheKey); existing != "" {
|
||||
return existing, nil
|
||||
}
|
||||
if len(imageData) == 0 {
|
||||
return "", fmt.Errorf("cover data is empty")
|
||||
}
|
||||
if err := os.MkdirAll(cacheDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create cache dir: %w", err)
|
||||
}
|
||||
|
||||
jpgPath, pngPath := libraryCoverCachePaths(cacheDir, cacheKey)
|
||||
cachePath := jpgPath
|
||||
if strings.Contains(mimeType, "png") {
|
||||
cachePath = pngPath
|
||||
}
|
||||
if err := os.WriteFile(cachePath, imageData, 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write cover: %w", err)
|
||||
}
|
||||
return cachePath, nil
|
||||
}
|
||||
|
||||
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
|
||||
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
|
||||
if existing := existingLibraryCoverCachePath(cacheDir, cacheKey); existing != "" {
|
||||
return existing, nil
|
||||
hash := hashString(cacheKey)
|
||||
|
||||
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
|
||||
pngPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.png", hash))
|
||||
|
||||
if _, err := os.Stat(jpgPath); err == nil {
|
||||
return jpgPath, nil
|
||||
}
|
||||
if _, err := os.Stat(pngPath); err == nil {
|
||||
return pngPath, nil
|
||||
}
|
||||
|
||||
imageData, mimeType, err := extractAnyCoverArtWithHint(filePath, displayNameHint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return saveLibraryCoverDataToCache(cacheDir, cacheKey, imageData, mimeType)
|
||||
|
||||
if err := os.MkdirAll(cacheDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create cache dir: %w", err)
|
||||
}
|
||||
|
||||
var cachePath string
|
||||
if strings.Contains(mimeType, "png") {
|
||||
cachePath = pngPath
|
||||
} else {
|
||||
cachePath = jpgPath
|
||||
}
|
||||
|
||||
if err := os.WriteFile(cachePath, imageData, 0644); err != nil {
|
||||
return "", fmt.Errorf("failed to write cover: %w", err)
|
||||
}
|
||||
|
||||
return cachePath, nil
|
||||
}
|
||||
|
||||
@@ -85,6 +85,9 @@ func TestAudioMetadataID3ParsingBranches(t *testing.T) {
|
||||
if n, total := parseIndexPair(" 8 / 10 "); n != 8 || total != 10 {
|
||||
t.Fatalf("parseIndexPair = %d/%d", n, total)
|
||||
}
|
||||
if got := parseTrackNumber("9/11"); got != 9 {
|
||||
t.Fatalf("parseTrackNumber = %d", got)
|
||||
}
|
||||
if got := removeUnsync([]byte{0xff, 0x00, 0xe0}); !bytes.Equal(got, []byte{0xff, 0xe0}) {
|
||||
t.Fatalf("removeUnsync = %#v", got)
|
||||
}
|
||||
@@ -161,6 +164,12 @@ func TestAudioMetadataCoverAndQualityHelpers(t *testing.T) {
|
||||
if commentMIME != "image/png" || !bytes.Equal(commentImage, png) {
|
||||
t.Fatalf("vorbis picture = %s/%v", commentMIME, commentImage)
|
||||
}
|
||||
decoded := make([]byte, base64StdDecodeLen(len("SGV sbG8="))+4)
|
||||
n, err := base64StdDecode(decoded, []byte("SGV sbG8="))
|
||||
if err != nil || strings.TrimRight(string(decoded[:n]), "\x00") != "Hello" {
|
||||
t.Fatalf("base64 decode = %q/%v", decoded[:n], err)
|
||||
}
|
||||
|
||||
if detectOggStreamType([][]byte{[]byte("OpusHeadxxxx")}) != oggStreamOpus {
|
||||
t.Fatal("expected opus stream")
|
||||
}
|
||||
@@ -421,6 +430,9 @@ func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
|
||||
if image, mime, err := extractAnyCoverArtWithHint(coverPath, "cover.opus"); err != nil || mime != "image/png" || len(image) == 0 {
|
||||
t.Fatalf("extractAnyCoverArtWithHint = %s/%#v/%v", mime, image, err)
|
||||
}
|
||||
if image, mime, err := extractAnyCoverArt(coverPath); err != nil || mime != "image/png" || len(image) == 0 {
|
||||
t.Fatalf("extractAnyCoverArt = %s/%#v/%v", mime, image, err)
|
||||
}
|
||||
extractedCoverPath := filepath.Join(dir, "extracted.png")
|
||||
if err := ExtractCoverToFile(coverPath, extractedCoverPath); err != nil {
|
||||
t.Fatalf("ExtractCoverToFile = %v", err)
|
||||
@@ -432,6 +444,17 @@ func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
|
||||
if err != nil || cachePath == "" {
|
||||
t.Fatalf("SaveCoverToCacheWithHintAndKey = %q/%v", cachePath, err)
|
||||
}
|
||||
cacheDir := filepath.Join(dir, "cache")
|
||||
if path, err := SaveCoverToCache(coverPath, cacheDir); err != nil || !strings.HasSuffix(path, ".png") {
|
||||
t.Fatalf("SaveCoverToCache = %q/%v", path, err)
|
||||
}
|
||||
if path, err := SaveCoverToCacheWithHint(coverPath, "cover.opus", cacheDir); err != nil || path == "" {
|
||||
t.Fatalf("SaveCoverToCacheWithHint = %q/%v", path, err)
|
||||
}
|
||||
hitPath, err := SaveCoverToCache(coverPath, cacheDir)
|
||||
if err != nil || hitPath == "" {
|
||||
t.Fatalf("SaveCoverToCache cache hit = %q/%v", hitPath, err)
|
||||
}
|
||||
if _, err := SaveCoverToCacheWithHintAndKey(filepath.Join(dir, "missing.opus"), "missing.opus", dir, "missing"); err == nil {
|
||||
t.Fatal("expected missing cover cache error")
|
||||
}
|
||||
@@ -492,16 +515,3 @@ func buildTestFLACPictureBlock(image []byte, mime string) []byte {
|
||||
picture.Write(image)
|
||||
return picture.Bytes()
|
||||
}
|
||||
|
||||
func TestR128ToReplayGainDb(t *testing.T) {
|
||||
// -1280/256 = -5 dB on the R128 (-23 LUFS) scale -> 0 dB ReplayGain (-18).
|
||||
if db, ok := r128ToReplayGainDb("-1280"); !ok || db != "0.00 dB" {
|
||||
t.Fatalf("got %q ok=%v", db, ok)
|
||||
}
|
||||
if db, ok := r128ToReplayGainDb(" -2944 "); !ok || db != "-6.50 dB" {
|
||||
t.Fatalf("got %q ok=%v", db, ok)
|
||||
}
|
||||
if _, ok := r128ToReplayGainDb("abc"); ok {
|
||||
t.Fatal("expected failure for non-numeric input")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,18 +59,6 @@ func initDownloadCancel(itemID string) context.Context {
|
||||
return ctx
|
||||
}
|
||||
|
||||
func downloadCancelContext(itemID string) context.Context {
|
||||
if itemID == "" {
|
||||
return context.Background()
|
||||
}
|
||||
cancelMu.Lock()
|
||||
defer cancelMu.Unlock()
|
||||
if entry, ok := cancelMap[itemID]; ok && entry.ctx != nil {
|
||||
return entry.ctx
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
func cancelDownload(itemID string) {
|
||||
if itemID == "" {
|
||||
return
|
||||
@@ -103,22 +91,6 @@ func isDownloadCancelled(itemID string) bool {
|
||||
return canceled
|
||||
}
|
||||
|
||||
// resetDownloadCancel removes a cancellation entry that has no active
|
||||
// download attached (refs <= 0). Such entries exist to catch an item that is
|
||||
// just about to start, but if the item never starts the flag lingers and the
|
||||
// next explicit retry would consume it and abort immediately.
|
||||
func resetDownloadCancel(itemID string) {
|
||||
if itemID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
if entry, ok := cancelMap[itemID]; ok && entry.refs <= 0 {
|
||||
delete(cancelMap, itemID)
|
||||
}
|
||||
cancelMu.Unlock()
|
||||
}
|
||||
|
||||
func clearDownloadCancel(itemID string) {
|
||||
if itemID == "" {
|
||||
return
|
||||
@@ -165,18 +137,6 @@ func initExtensionRequestCancel(requestID string) context.Context {
|
||||
return ctx
|
||||
}
|
||||
|
||||
func extensionRequestCancelContext(requestID string) context.Context {
|
||||
if requestID == "" {
|
||||
return context.Background()
|
||||
}
|
||||
extensionRequestCancelMu.Lock()
|
||||
defer extensionRequestCancelMu.Unlock()
|
||||
if entry, ok := extensionRequestCancelMap[requestID]; ok && entry.ctx != nil {
|
||||
return entry.ctx
|
||||
}
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
func cancelExtensionRequest(requestID string) {
|
||||
if requestID == "" {
|
||||
return
|
||||
|
||||
+14
-111
@@ -6,8 +6,6 @@ import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -54,115 +52,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
|
||||
GoLog("[Cover] Final URL: %s", downloadURL)
|
||||
|
||||
data, err := fetchCoverCached(downloadURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Cached bytes are shared across goroutines and must never be mutated;
|
||||
// hand callers their own copy.
|
||||
return append([]byte(nil), data...), nil
|
||||
}
|
||||
|
||||
const (
|
||||
coverCacheMaxBytes = 24 * 1024 * 1024
|
||||
coverCacheTTL = 15 * time.Minute
|
||||
)
|
||||
|
||||
type coverCacheEntry struct {
|
||||
data []byte
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type coverInflightCall struct {
|
||||
wg sync.WaitGroup
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
|
||||
var (
|
||||
coverMu sync.Mutex
|
||||
coverCache = map[string]*coverCacheEntry{}
|
||||
coverCacheBytes int
|
||||
coverInflight = map[string]*coverInflightCall{}
|
||||
coverFetch = fetchCoverBytes
|
||||
)
|
||||
|
||||
func clearCoverMemoryCache() {
|
||||
coverMu.Lock()
|
||||
coverCache = map[string]*coverCacheEntry{}
|
||||
coverCacheBytes = 0
|
||||
coverMu.Unlock()
|
||||
}
|
||||
|
||||
// fetchCoverCached returns cover bytes for a final URL, collapsing concurrent
|
||||
// requests for the same URL into a single fetch (singleflight) and caching
|
||||
// results in memory for the duration of an album batch. The returned slice is
|
||||
// shared; callers must copy before mutating.
|
||||
func fetchCoverCached(downloadURL string) ([]byte, error) {
|
||||
coverMu.Lock()
|
||||
if e, ok := coverCache[downloadURL]; ok {
|
||||
if time.Now().Before(e.expiresAt) {
|
||||
data := e.data
|
||||
coverMu.Unlock()
|
||||
return data, nil
|
||||
}
|
||||
delete(coverCache, downloadURL)
|
||||
coverCacheBytes -= len(e.data)
|
||||
}
|
||||
if call, ok := coverInflight[downloadURL]; ok {
|
||||
coverMu.Unlock()
|
||||
call.wg.Wait()
|
||||
return call.data, call.err
|
||||
}
|
||||
call := &coverInflightCall{}
|
||||
// Default error so a panicking fetch never strands waiters with a
|
||||
// (nil, nil) "success"; overwritten on normal completion.
|
||||
call.err = fmt.Errorf("cover fetch aborted")
|
||||
call.wg.Add(1)
|
||||
coverInflight[downloadURL] = call
|
||||
coverMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
call.wg.Done()
|
||||
coverMu.Lock()
|
||||
delete(coverInflight, downloadURL)
|
||||
coverMu.Unlock()
|
||||
}()
|
||||
|
||||
data, err := coverFetch(downloadURL)
|
||||
call.data, call.err = data, err
|
||||
if err == nil {
|
||||
coverCachePut(downloadURL, data)
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
func coverCachePut(downloadURL string, data []byte) {
|
||||
if len(data) == 0 || len(data) > coverCacheMaxBytes {
|
||||
return
|
||||
}
|
||||
coverMu.Lock()
|
||||
defer coverMu.Unlock()
|
||||
if e, ok := coverCache[downloadURL]; ok {
|
||||
coverCacheBytes -= len(e.data)
|
||||
}
|
||||
coverCache[downloadURL] = &coverCacheEntry{data: data, expiresAt: time.Now().Add(coverCacheTTL)}
|
||||
coverCacheBytes += len(data)
|
||||
for coverCacheBytes > coverCacheMaxBytes && len(coverCache) > 1 {
|
||||
var oldestKey string
|
||||
var oldest time.Time
|
||||
first := true
|
||||
for k, e := range coverCache {
|
||||
if first || e.expiresAt.Before(oldest) {
|
||||
oldest, oldestKey, first = e.expiresAt, k, false
|
||||
}
|
||||
}
|
||||
coverCacheBytes -= len(coverCache[oldestKey].data)
|
||||
delete(coverCache, oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchCoverBytes(downloadURL string) ([]byte, error) {
|
||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||
|
||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||
@@ -254,3 +143,17 @@ func upgradeQobuzCover(coverURL string) string {
|
||||
}
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||
if imageURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
result := convertSmallToMedium(imageURL)
|
||||
|
||||
if maxQuality {
|
||||
result = upgradeToMaxQuality(result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func resetCoverCache() {
|
||||
coverMu.Lock()
|
||||
coverCache = map[string]*coverCacheEntry{}
|
||||
coverInflight = map[string]*coverInflightCall{}
|
||||
coverCacheBytes = 0
|
||||
coverMu.Unlock()
|
||||
}
|
||||
|
||||
func TestFetchCoverCachedSingleflight(t *testing.T) {
|
||||
orig := coverFetch
|
||||
defer func() { coverFetch = orig }()
|
||||
resetCoverCache()
|
||||
|
||||
var calls int32
|
||||
entered := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
coverFetch = func(string) ([]byte, error) {
|
||||
if atomic.AddInt32(&calls, 1) == 1 {
|
||||
close(entered)
|
||||
}
|
||||
<-release
|
||||
return []byte("coverbytes"), nil
|
||||
}
|
||||
|
||||
const url = "https://cdn.example/cover_max.jpg"
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := fetchCoverCached(url); err != nil {
|
||||
t.Errorf("leader fetch error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-entered // leader has registered inflight and is blocked in coverFetch
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if _, err := fetchCoverCached(url); err != nil {
|
||||
t.Errorf("follower fetch error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
close(release)
|
||||
wg.Wait()
|
||||
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected 1 fetch for concurrent requests, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchCoverCachedTTLExpiry(t *testing.T) {
|
||||
orig := coverFetch
|
||||
defer func() { coverFetch = orig }()
|
||||
resetCoverCache()
|
||||
|
||||
var calls int32
|
||||
coverFetch = func(string) ([]byte, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return []byte("data"), nil
|
||||
}
|
||||
|
||||
const url = "https://cdn.example/ttl.jpg"
|
||||
if _, err := fetchCoverCached(url); err != nil {
|
||||
t.Fatalf("first fetch error: %v", err)
|
||||
}
|
||||
// second call served from cache
|
||||
if _, err := fetchCoverCached(url); err != nil {
|
||||
t.Fatalf("second fetch error: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("expected cache hit, got %d fetches", got)
|
||||
}
|
||||
|
||||
// expire the entry and confirm a refetch
|
||||
coverMu.Lock()
|
||||
coverCache[url].expiresAt = time.Now().Add(-time.Minute)
|
||||
coverMu.Unlock()
|
||||
|
||||
if _, err := fetchCoverCached(url); err != nil {
|
||||
t.Fatalf("third fetch error: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||
t.Fatalf("expected refetch after TTL expiry, got %d fetches", got)
|
||||
}
|
||||
}
|
||||
@@ -73,8 +73,7 @@ function track(id) {
|
||||
genre: "Pop",
|
||||
composer: "Composer",
|
||||
audioQuality: "FLAC 24-bit",
|
||||
audioModes: "DOLBY_ATMOS",
|
||||
explicit: true
|
||||
audioModes: "DOLBY_ATMOS"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ func searchCollectionCandidates(provider *extensionProviderWrapper, itemType str
|
||||
}
|
||||
|
||||
if filter != "" {
|
||||
tracks, err := provider.CustomSearch(query, map[string]any{
|
||||
tracks, err := provider.CustomSearch(query, map[string]interface{}{
|
||||
"filter": filter,
|
||||
"limit": 10,
|
||||
})
|
||||
@@ -409,7 +409,7 @@ func templateShareURL(ext *loadedExtension, itemType string, id string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]any)
|
||||
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import "testing"
|
||||
func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
Manifest: &ExtensionManifest{
|
||||
Capabilities: map[string]any{
|
||||
"shareUrlTemplates": map[string]any{
|
||||
Capabilities: map[string]interface{}{
|
||||
"shareUrlTemplates": map[string]interface{}{
|
||||
"album": "https://music.apple.com/us/album/{id}",
|
||||
},
|
||||
},
|
||||
@@ -33,8 +33,8 @@ func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
|
||||
func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
Manifest: &ExtensionManifest{
|
||||
Capabilities: map[string]any{
|
||||
"shareUrlTemplates": map[string]any{
|
||||
Capabilities: map[string]interface{}{
|
||||
"shareUrlTemplates": map[string]interface{}{
|
||||
"artist": "https://music.youtube.com/browse/{id}",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -117,101 +117,6 @@ func TestCueParserEndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestFlacWithISRC(t *testing.T, path, isrc string) {
|
||||
t.Helper()
|
||||
le32 := func(v int) []byte {
|
||||
return []byte{byte(v), byte(v >> 8), byte(v >> 16), byte(v >> 24)}
|
||||
}
|
||||
vendor := "test-vendor"
|
||||
comments := [][]byte{
|
||||
[]byte("TITLE=Song"),
|
||||
[]byte("ISRC=" + isrc),
|
||||
}
|
||||
var vorbis []byte
|
||||
vorbis = append(vorbis, le32(len(vendor))...)
|
||||
vorbis = append(vorbis, vendor...)
|
||||
vorbis = append(vorbis, le32(len(comments))...)
|
||||
for _, comment := range comments {
|
||||
vorbis = append(vorbis, le32(len(comment))...)
|
||||
vorbis = append(vorbis, comment...)
|
||||
}
|
||||
|
||||
blockHeader := func(last bool, blockType byte, length int) []byte {
|
||||
first := blockType
|
||||
if last {
|
||||
first |= 0x80
|
||||
}
|
||||
return []byte{first, byte(length >> 16), byte(length >> 8), byte(length)}
|
||||
}
|
||||
|
||||
var data []byte
|
||||
data = append(data, "fLaC"...)
|
||||
data = append(data, blockHeader(false, 0, 34)...) // STREAMINFO
|
||||
data = append(data, make([]byte, 34)...)
|
||||
picture := make([]byte, 4096) // stands in for embedded cover art
|
||||
data = append(data, blockHeader(false, 6, len(picture))...)
|
||||
data = append(data, picture...)
|
||||
data = append(data, blockHeader(true, 4, len(vorbis))...)
|
||||
data = append(data, vorbis...)
|
||||
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestISRCIndexIncrementalRebuild(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
trackA := filepath.Join(dir, "a.flac")
|
||||
trackB := filepath.Join(dir, "b.flac")
|
||||
writeTestFlacWithISRC(t, trackA, "USAA00000001")
|
||||
writeTestFlacWithISRC(t, trackB, "USBB00000002")
|
||||
defer InvalidateISRCCache(dir)
|
||||
|
||||
if got := readFlacISRC(trackA); got != "USAA00000001" {
|
||||
t.Fatalf("readFlacISRC = %q", got)
|
||||
}
|
||||
if got := readFlacISRC(filepath.Join(dir, "missing.flac")); got != "" {
|
||||
t.Fatalf("expected empty ISRC for missing file, got %q", got)
|
||||
}
|
||||
|
||||
idx := buildISRCIndex(dir)
|
||||
if path, ok := idx.lookup("usaa00000001"); !ok || path != trackA {
|
||||
t.Fatalf("lookup A = %q/%v", path, ok)
|
||||
}
|
||||
if path, ok := idx.lookup("USBB00000002"); !ok || path != trackB {
|
||||
t.Fatalf("lookup B = %q/%v", path, ok)
|
||||
}
|
||||
|
||||
// Change one file's tag (and mtime); an incremental rebuild must pick up
|
||||
// the change while adopting the untouched file from the cache.
|
||||
writeTestFlacWithISRC(t, trackB, "USBB00000099")
|
||||
future := time.Now().Add(2 * time.Second)
|
||||
if err := os.Chtimes(trackB, future, future); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rebuilt := buildISRCIndex(dir)
|
||||
if _, ok := rebuilt.lookup("USBB00000002"); ok {
|
||||
t.Fatal("expected stale ISRC to disappear after rebuild")
|
||||
}
|
||||
if path, ok := rebuilt.lookup("USBB00000099"); !ok || path != trackB {
|
||||
t.Fatalf("rebuilt lookup = %q/%v", path, ok)
|
||||
}
|
||||
if path, ok := rebuilt.lookup("USAA00000001"); !ok || path != trackA {
|
||||
t.Fatalf("cached entry lost on rebuild: %q/%v", path, ok)
|
||||
}
|
||||
|
||||
// Add() keeps the index fresh so the TTL never forces a rebuild while
|
||||
// downloads are actively maintaining it.
|
||||
rebuilt.buildTime.Store(time.Now().Add(-isrcIndexTTL - time.Minute).UnixNano())
|
||||
if rebuilt.isFresh() {
|
||||
t.Fatal("expected stale index")
|
||||
}
|
||||
rebuilt.Add("USCC00000003", trackA)
|
||||
if !rebuilt.isFresh() {
|
||||
t.Fatal("expected Add to refresh the index timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateIndexAndParallelExistence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
filePath := filepath.Join(dir, "song.flac")
|
||||
@@ -219,8 +124,7 @@ func TestDuplicateIndexAndParallelExistence(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
idx := &ISRCIndex{index: map[string]string{}, outputDir: dir}
|
||||
idx.buildTime.Store(time.Now().UnixNano())
|
||||
idx := &ISRCIndex{index: map[string]string{}, outputDir: dir, buildTime: time.Now()}
|
||||
idx.Add("usrc17607839", filePath)
|
||||
if got, ok := idx.lookup("USRC17607839"); !ok || got != filePath {
|
||||
t.Fatalf("lookup = %q/%v", got, ok)
|
||||
|
||||
+25
-57
@@ -3,7 +3,6 @@ package gobackend
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -163,26 +162,17 @@ func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) {
|
||||
}
|
||||
|
||||
type deezerTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Duration int `json:"duration"`
|
||||
TrackPosition int `json:"track_position"`
|
||||
DiskNumber int `json:"disk_number"`
|
||||
ISRC string `json:"isrc"`
|
||||
Link string `json:"link"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ExplicitLyrics bool `json:"explicit_lyrics"`
|
||||
ExplicitContentLyrics int `json:"explicit_content_lyrics"`
|
||||
Artist deezerArtist `json:"artist"`
|
||||
Album deezerAlbumSimple `json:"album"`
|
||||
Contributors []deezerArtist `json:"contributors"`
|
||||
}
|
||||
|
||||
// deezerTrackIsExplicit maps Deezer's parental-advisory fields to a boolean:
|
||||
// explicit_lyrics is the boolean flag, explicit_content_lyrics uses 1 to mean
|
||||
// explicit (0 = clean, 2 = unknown).
|
||||
func deezerTrackIsExplicit(track deezerTrack) bool {
|
||||
return track.ExplicitLyrics || track.ExplicitContentLyrics == 1
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Duration int `json:"duration"`
|
||||
TrackPosition int `json:"track_position"`
|
||||
DiskNumber int `json:"disk_number"`
|
||||
ISRC string `json:"isrc"`
|
||||
Link string `json:"link"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Artist deezerArtist `json:"artist"`
|
||||
Album deezerAlbumSimple `json:"album"`
|
||||
Contributors []deezerArtist `json:"contributors"`
|
||||
}
|
||||
|
||||
type deezerArtist struct {
|
||||
@@ -254,7 +244,6 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||
ISRC: track.ISRC,
|
||||
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
|
||||
ArtistID: fmt.Sprintf("deezer:%d", track.Artist.ID),
|
||||
Explicit: deezerTrackIsExplicit(track),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,7 +669,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
ISRC: isrc,
|
||||
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
||||
AlbumType: albumType,
|
||||
Explicit: deezerTrackIsExplicit(track),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -795,6 +783,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
||||
// not include this field. Albums whose track count is already known (non-zero)
|
||||
// are skipped.
|
||||
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
|
||||
// Find albums that need track counts
|
||||
type indexedID struct {
|
||||
idx int
|
||||
albumID string
|
||||
@@ -990,7 +979,6 @@ func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*Pla
|
||||
ExternalURL: track.Link,
|
||||
ISRC: isrc,
|
||||
AlbumID: fmt.Sprintf("deezer:%d", track.Album.ID),
|
||||
Explicit: deezerTrackIsExplicit(track),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1263,16 +1251,12 @@ func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc strin
|
||||
return c.GetExtendedMetadataByTrackID(ctx, deezerID)
|
||||
}
|
||||
|
||||
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst any) error {
|
||||
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := deezerRetryDelay * time.Duration(1<<(attempt-1))
|
||||
var apiErr *deezerAPIError
|
||||
if errors.As(lastErr, &apiErr) && apiErr.RetryAfter > 0 {
|
||||
delay = apiErr.RetryAfter
|
||||
}
|
||||
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
@@ -1283,7 +1267,16 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst any) er
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
if !isDeezerRetryableError(err) {
|
||||
errStr := err.Error()
|
||||
|
||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "connection reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "EOF") ||
|
||||
strings.Contains(errStr, "status 5") ||
|
||||
strings.Contains(errStr, "status 429")
|
||||
|
||||
if !isRetryable {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1293,38 +1286,13 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst any) er
|
||||
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
type deezerAPIError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
|
||||
func (e *deezerAPIError) Error() string {
|
||||
return fmt.Sprintf("deezer API returned status %d: %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
func isDeezerRetryableError(err error) bool {
|
||||
if isConnectivityFailure(err) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return true
|
||||
}
|
||||
var apiErr *deezerAPIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode >= http.StatusInternalServerError
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst any) error {
|
||||
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
// Without an explicit language Deezer localizes artist/genre names by
|
||||
// the caller's IP geolocation (issue #480: Arabic metadata on an
|
||||
// English device). Follow the app's display language instead.
|
||||
req.Header.Set("Accept-Language", metadataAcceptLanguage())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -1338,7 +1306,7 @@ func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst any)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &deezerAPIError{StatusCode: resp.StatusCode, Body: string(body), RetryAfter: getRetryAfterDuration(resp)}
|
||||
return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return json.Unmarshal(body, dst)
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/dns/dnsmessage"
|
||||
)
|
||||
|
||||
// DNS-over-HTTPS fallback for DNS-level ISP blocking. The OS resolver stays
|
||||
// the primary path; only when it fails with a DNS error (NXDOMAIN, SERVFAIL,
|
||||
// refused, resolver timeout) is the host re-resolved over DoH to hardcoded
|
||||
// resolver IPs and dialed directly. TLS verification still runs against the
|
||||
// original hostname, so a bad answer cannot silently redirect traffic.
|
||||
|
||||
var dohUpstreams = []string{
|
||||
"https://1.1.1.1/dns-query",
|
||||
"https://8.8.8.8/dns-query",
|
||||
}
|
||||
|
||||
// Upstream URLs are literal IPs, so this client never needs DNS itself.
|
||||
var dohClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
|
||||
MaxIdleConnsPerHost: 1,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSClientConfig: newTLSCompatibilityConfig(false),
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
const (
|
||||
dohCacheMaxEntries = 256
|
||||
dohCacheMinTTL = time.Minute
|
||||
dohCacheMaxTTL = 30 * time.Minute
|
||||
dohCacheErrorTTL = 30 * time.Second
|
||||
)
|
||||
|
||||
type dohCacheEntry struct {
|
||||
ips []net.IP
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
dohMu sync.Mutex
|
||||
dohCache = map[string]dohCacheEntry{}
|
||||
)
|
||||
|
||||
// dialWithDoHFallback dials addr normally and, when the failure is a DNS
|
||||
// error, retries with DoH-resolved addresses. Non-DNS failures pass through
|
||||
// untouched.
|
||||
func dialWithDoHFallback(ctx context.Context, dialer *net.Dialer, network, addr string) (net.Conn, error) {
|
||||
conn, err := dialer.DialContext(ctx, network, addr)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
var dnsErr *net.DNSError
|
||||
if !errors.As(err, &dnsErr) {
|
||||
return nil, err
|
||||
}
|
||||
host, port, splitErr := net.SplitHostPort(addr)
|
||||
if splitErr != nil || net.ParseIP(host) != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips, dohErr := dohResolve(ctx, host)
|
||||
if dohErr != nil {
|
||||
// Surface the OS resolver's error, not the fallback's.
|
||||
return nil, err
|
||||
}
|
||||
GoLog("[DoH] OS resolver failed for %s (%v), dialing DoH answer\n", host, err)
|
||||
lastErr := err
|
||||
for _, ip := range ips {
|
||||
conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
if dialErr == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// dohResolve resolves host over DoH, IPv4 first. Failures are negative-cached
|
||||
// briefly so a burst of dials does not hammer the resolvers.
|
||||
func dohResolve(ctx context.Context, host string) ([]net.IP, error) {
|
||||
if ips, ok := dohCachedIPs(host); ok {
|
||||
if len(ips) == 0 {
|
||||
return nil, fmt.Errorf("doh: cached failure for %s", host)
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, upstream := range dohUpstreams {
|
||||
ips, ttl, err := dohQuery(ctx, upstream, host, dnsmessage.TypeA)
|
||||
if err == nil && len(ips) == 0 {
|
||||
ips, ttl, err = dohQuery(ctx, upstream, host, dnsmessage.TypeAAAA)
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
ips = filterDialableIPs(ips)
|
||||
if len(ips) == 0 {
|
||||
break
|
||||
}
|
||||
dohCachePut(host, ips, min(max(ttl, dohCacheMinTTL), dohCacheMaxTTL))
|
||||
return ips, nil
|
||||
}
|
||||
dohCachePut(host, nil, dohCacheErrorTTL)
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("doh: no address for %s", host)
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func dohQuery(ctx context.Context, upstream, host string, qtype dnsmessage.Type) ([]net.IP, time.Duration, error) {
|
||||
name, err := dnsmessage.NewName(host + ".")
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("doh: invalid host %q: %w", host, err)
|
||||
}
|
||||
msg := dnsmessage.Message{
|
||||
Header: dnsmessage.Header{RecursionDesired: true},
|
||||
Questions: []dnsmessage.Question{{
|
||||
Name: name,
|
||||
Type: qtype,
|
||||
Class: dnsmessage.ClassINET,
|
||||
}},
|
||||
}
|
||||
packed, err := msg.Pack()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstream, bytes.NewReader(packed))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/dns-message")
|
||||
req.Header.Set("Accept", "application/dns-message")
|
||||
|
||||
resp, err := dohClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, 0, fmt.Errorf("doh: %s answered HTTP %d", upstream, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var reply dnsmessage.Message
|
||||
if err := reply.Unpack(body); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if reply.RCode != dnsmessage.RCodeSuccess {
|
||||
return nil, 0, fmt.Errorf("doh: rcode %v for %s", reply.RCode, host)
|
||||
}
|
||||
|
||||
var ips []net.IP
|
||||
ttl := dohCacheMaxTTL
|
||||
for _, ans := range reply.Answers {
|
||||
var ip net.IP
|
||||
switch r := ans.Body.(type) {
|
||||
case *dnsmessage.AResource:
|
||||
ip = net.IP(r.A[:])
|
||||
case *dnsmessage.AAAAResource:
|
||||
ip = net.IP(r.AAAA[:])
|
||||
default:
|
||||
continue
|
||||
}
|
||||
ips = append(ips, ip)
|
||||
if t := time.Duration(ans.Header.TTL) * time.Second; t < ttl {
|
||||
ttl = t
|
||||
}
|
||||
}
|
||||
return ips, ttl, nil
|
||||
}
|
||||
|
||||
// filterDialableIPs drops private/loopback/link-local answers unless the user
|
||||
// opted into private-network access — a DoH answer must not bypass the SSRF
|
||||
// guard the OS-resolver path enforces.
|
||||
func filterDialableIPs(ips []net.IP) []net.IP {
|
||||
if IsPrivateNetworkAllowed() {
|
||||
return ips
|
||||
}
|
||||
kept := ips[:0]
|
||||
for _, ip := range ips {
|
||||
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, ip)
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
func dohCachedIPs(host string) ([]net.IP, bool) {
|
||||
dohMu.Lock()
|
||||
defer dohMu.Unlock()
|
||||
e, ok := dohCache[host]
|
||||
if !ok || time.Now().After(e.expiresAt) {
|
||||
return nil, false
|
||||
}
|
||||
return e.ips, true
|
||||
}
|
||||
|
||||
func dohCachePut(host string, ips []net.IP, ttl time.Duration) {
|
||||
dohMu.Lock()
|
||||
defer dohMu.Unlock()
|
||||
if len(dohCache) >= dohCacheMaxEntries {
|
||||
now := time.Now()
|
||||
for k, e := range dohCache {
|
||||
if now.After(e.expiresAt) {
|
||||
delete(dohCache, k)
|
||||
}
|
||||
}
|
||||
for k := range dohCache {
|
||||
if len(dohCache) < dohCacheMaxEntries {
|
||||
break
|
||||
}
|
||||
delete(dohCache, k)
|
||||
}
|
||||
}
|
||||
dohCache[host] = dohCacheEntry{ips: ips, expiresAt: time.Now().Add(ttl)}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// downloadPathLocks serializes writes per final output path so concurrent
|
||||
// downloads that resolve to the same file cannot interleave bytes into one
|
||||
// output or race the staged-promote rename. Keys are normalized case-folded
|
||||
// cleaned paths; entries live for the process lifetime (bounded by the number
|
||||
// of distinct output files in a session).
|
||||
var downloadPathLocks sync.Map
|
||||
|
||||
// lockDownloadOutputPath locks the given final output path and returns the
|
||||
// unlock function. Different paths keep downloading in parallel; a second
|
||||
// download of the same path blocks until the first finishes.
|
||||
func lockDownloadOutputPath(path string) func() {
|
||||
key := strings.ToLower(filepath.Clean(path))
|
||||
value, _ := downloadPathLocks.LoadOrStore(key, &sync.Mutex{})
|
||||
mu := value.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
// stagedDownloadPath returns the sibling name downloads are streamed into
|
||||
// before being promoted to the final path with an atomic rename. The suffix
|
||||
// keeps the staged file invisible to extension-based duplicate checks, which
|
||||
// match on the final audio extension.
|
||||
func stagedDownloadPath(finalPath string) string {
|
||||
return finalPath + ".partial"
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
downloadPreparationCacheTTL = 5 * time.Minute
|
||||
downloadPreparationCacheMax = 128
|
||||
)
|
||||
|
||||
type preparedDownloadRequestEntry struct {
|
||||
key string
|
||||
request DownloadRequest
|
||||
metadataPrepared bool
|
||||
createdAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
preparedDownloadRequests = make(map[string]preparedDownloadRequestEntry)
|
||||
preparedDownloadRequestsMu sync.Mutex
|
||||
)
|
||||
|
||||
func downloadPreparationKey(req DownloadRequest) string {
|
||||
return strings.Join([]string{
|
||||
strings.TrimSpace(req.ItemID),
|
||||
strings.ToLower(strings.TrimSpace(req.Service)),
|
||||
strings.ToLower(strings.TrimSpace(req.Source)),
|
||||
strings.TrimSpace(req.SpotifyID),
|
||||
strings.TrimSpace(req.TidalID),
|
||||
strings.TrimSpace(req.QobuzID),
|
||||
strings.TrimSpace(req.DeezerID),
|
||||
strings.ToLower(strings.TrimSpace(req.TrackName)),
|
||||
strings.ToLower(strings.TrimSpace(req.ArtistName)),
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func prunePreparedDownloadRequestsLocked(now time.Time) {
|
||||
for itemID, entry := range preparedDownloadRequests {
|
||||
if now.Sub(entry.createdAt) >= downloadPreparationCacheTTL {
|
||||
delete(preparedDownloadRequests, itemID)
|
||||
}
|
||||
}
|
||||
for len(preparedDownloadRequests) >= downloadPreparationCacheMax {
|
||||
var oldestID string
|
||||
var oldestAt time.Time
|
||||
for itemID, entry := range preparedDownloadRequests {
|
||||
if oldestID == "" || entry.createdAt.Before(oldestAt) {
|
||||
oldestID = itemID
|
||||
oldestAt = entry.createdAt
|
||||
}
|
||||
}
|
||||
if oldestID == "" {
|
||||
break
|
||||
}
|
||||
delete(preparedDownloadRequests, oldestID)
|
||||
}
|
||||
}
|
||||
|
||||
func cacheDownloadRequestForVerification(key string, req DownloadRequest, metadataPrepared bool) {
|
||||
itemID := strings.TrimSpace(req.ItemID)
|
||||
if itemID == "" || strings.TrimSpace(key) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
preparedDownloadRequestsMu.Lock()
|
||||
defer preparedDownloadRequestsMu.Unlock()
|
||||
now := time.Now()
|
||||
prunePreparedDownloadRequestsLocked(now)
|
||||
preparedDownloadRequests[itemID] = preparedDownloadRequestEntry{
|
||||
key: key,
|
||||
request: req,
|
||||
metadataPrepared: metadataPrepared,
|
||||
createdAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func cachePreparedDownloadRequest(key string, req DownloadRequest) {
|
||||
cacheDownloadRequestForVerification(key, req, true)
|
||||
}
|
||||
|
||||
func cacheUnpreparedDownloadRequest(key string, req DownloadRequest) {
|
||||
cacheDownloadRequestForVerification(key, req, false)
|
||||
}
|
||||
|
||||
func takePreparedDownloadRequest(key string, fresh DownloadRequest) (DownloadRequest, bool, bool) {
|
||||
itemID := strings.TrimSpace(fresh.ItemID)
|
||||
if itemID == "" || strings.TrimSpace(key) == "" {
|
||||
return fresh, false, false
|
||||
}
|
||||
|
||||
preparedDownloadRequestsMu.Lock()
|
||||
defer preparedDownloadRequestsMu.Unlock()
|
||||
now := time.Now()
|
||||
prunePreparedDownloadRequestsLocked(now)
|
||||
entry, ok := preparedDownloadRequests[itemID]
|
||||
if !ok {
|
||||
return fresh, false, false
|
||||
}
|
||||
delete(preparedDownloadRequests, itemID)
|
||||
if entry.key != key {
|
||||
return fresh, false, false
|
||||
}
|
||||
|
||||
prepared := entry.request
|
||||
fresh.ISRC = prepared.ISRC
|
||||
fresh.SpotifyID = prepared.SpotifyID
|
||||
fresh.TrackName = prepared.TrackName
|
||||
fresh.ArtistName = prepared.ArtistName
|
||||
fresh.AlbumName = prepared.AlbumName
|
||||
fresh.AlbumArtist = prepared.AlbumArtist
|
||||
fresh.CoverURL = prepared.CoverURL
|
||||
fresh.TrackNumber = prepared.TrackNumber
|
||||
fresh.DiscNumber = prepared.DiscNumber
|
||||
fresh.TotalTracks = prepared.TotalTracks
|
||||
fresh.TotalDiscs = prepared.TotalDiscs
|
||||
fresh.ReleaseDate = prepared.ReleaseDate
|
||||
fresh.DurationMS = prepared.DurationMS
|
||||
fresh.Genre = prepared.Genre
|
||||
fresh.Label = prepared.Label
|
||||
fresh.Copyright = prepared.Copyright
|
||||
fresh.Composer = prepared.Composer
|
||||
fresh.TidalID = prepared.TidalID
|
||||
fresh.QobuzID = prepared.QobuzID
|
||||
fresh.DeezerID = prepared.DeezerID
|
||||
return fresh, entry.metadataPrepared, true
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func resetPreparedDownloadRequestCacheForTest() {
|
||||
preparedDownloadRequestsMu.Lock()
|
||||
preparedDownloadRequests = make(map[string]preparedDownloadRequestEntry)
|
||||
preparedDownloadRequestsMu.Unlock()
|
||||
}
|
||||
|
||||
func TestPreparedDownloadRequestCache(t *testing.T) {
|
||||
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
|
||||
resetPreparedDownloadRequestCacheForTest()
|
||||
|
||||
fresh := DownloadRequest{
|
||||
ItemID: "item-1",
|
||||
Service: "provider-a",
|
||||
Source: "source-a",
|
||||
SpotifyID: "spotify-1",
|
||||
TrackName: "Track",
|
||||
ArtistName: "Artist",
|
||||
OutputDir: "/new/output",
|
||||
OutputPath: "/new/output/current.flac",
|
||||
OutputFD: 42,
|
||||
Quality: "lossless",
|
||||
EmbedMetadata: true,
|
||||
}
|
||||
key := downloadPreparationKey(fresh)
|
||||
prepared := fresh
|
||||
prepared.ISRC = "USRC17607839"
|
||||
prepared.AlbumName = "Resolved Album"
|
||||
prepared.AlbumArtist = "Resolved Album Artist"
|
||||
prepared.DeezerID = "deezer-1"
|
||||
prepared.Genre = "Pop"
|
||||
prepared.OutputDir = "/stale/output"
|
||||
prepared.OutputPath = "/stale/output/old.flac"
|
||||
prepared.OutputFD = 7
|
||||
prepared.Quality = "stale-quality"
|
||||
prepared.EmbedMetadata = false
|
||||
cachePreparedDownloadRequest(key, prepared)
|
||||
|
||||
got, metadataPrepared, ok := takePreparedDownloadRequest(key, fresh)
|
||||
if !ok {
|
||||
t.Fatal("expected prepared request cache hit")
|
||||
}
|
||||
if !metadataPrepared {
|
||||
t.Fatal("prepared request should be marked as metadata-prepared")
|
||||
}
|
||||
if got.ISRC != prepared.ISRC || got.AlbumName != prepared.AlbumName || got.DeezerID != prepared.DeezerID || got.Genre != prepared.Genre {
|
||||
t.Fatalf("prepared metadata was not restored: %#v", got)
|
||||
}
|
||||
if got.OutputDir != fresh.OutputDir || got.OutputPath != fresh.OutputPath || got.OutputFD != fresh.OutputFD || got.Quality != fresh.Quality || got.EmbedMetadata != fresh.EmbedMetadata {
|
||||
t.Fatalf("fresh output/settings fields were overwritten: %#v", got)
|
||||
}
|
||||
if _, _, ok := takePreparedDownloadRequest(key, fresh); ok {
|
||||
t.Fatal("prepared request should be consumed after one retry")
|
||||
}
|
||||
|
||||
cacheUnpreparedDownloadRequest(key, fresh)
|
||||
_, metadataPrepared, ok = takePreparedDownloadRequest(key, fresh)
|
||||
if !ok || metadataPrepared {
|
||||
t.Fatalf("unprepared verification request = hit:%v metadataPrepared:%v", ok, metadataPrepared)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparedDownloadRequestCacheRejectsChangedTrackAndExpiry(t *testing.T) {
|
||||
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
|
||||
resetPreparedDownloadRequestCacheForTest()
|
||||
|
||||
req := DownloadRequest{
|
||||
ItemID: "item-2",
|
||||
Service: "provider-a",
|
||||
SpotifyID: "spotify-2",
|
||||
TrackName: "Track",
|
||||
ArtistName: "Artist",
|
||||
}
|
||||
key := downloadPreparationKey(req)
|
||||
cachePreparedDownloadRequest(key, req)
|
||||
|
||||
changed := req
|
||||
changed.SpotifyID = "spotify-other"
|
||||
if _, _, ok := takePreparedDownloadRequest(downloadPreparationKey(changed), changed); ok {
|
||||
t.Fatal("changed track must not reuse another track's prepared metadata")
|
||||
}
|
||||
preparedDownloadRequestsMu.Lock()
|
||||
_, staleEntryExists := preparedDownloadRequests[req.ItemID]
|
||||
preparedDownloadRequestsMu.Unlock()
|
||||
if staleEntryExists {
|
||||
t.Fatal("mismatched prepared request should be discarded")
|
||||
}
|
||||
|
||||
cachePreparedDownloadRequest(key, req)
|
||||
preparedDownloadRequestsMu.Lock()
|
||||
entry := preparedDownloadRequests[req.ItemID]
|
||||
entry.createdAt = time.Now().Add(-downloadPreparationCacheTTL)
|
||||
preparedDownloadRequests[req.ItemID] = entry
|
||||
preparedDownloadRequestsMu.Unlock()
|
||||
if _, _, ok := takePreparedDownloadRequest(key, req); ok {
|
||||
t.Fatal("expired prepared request must not be reused")
|
||||
}
|
||||
}
|
||||
+41
-200
@@ -1,31 +1,19 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// isrcFileEntry caches the parse result for one file so index rebuilds only
|
||||
// re-read files whose size or mtime changed.
|
||||
type isrcFileEntry struct {
|
||||
size int64
|
||||
modTime int64 // UnixNano
|
||||
isrc string // uppercase; empty when the file carries no ISRC tag
|
||||
}
|
||||
|
||||
type ISRCIndex struct {
|
||||
index map[string]string // ISRC (uppercase) -> file path
|
||||
files map[string]isrcFileEntry // file path -> cached parse result
|
||||
index map[string]string // ISRC (uppercase) -> file path
|
||||
outputDir string
|
||||
buildTime atomic.Int64 // UnixNano of the last build or write
|
||||
buildTime time.Time
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
@@ -34,20 +22,14 @@ var (
|
||||
isrcIndexCacheMu sync.RWMutex
|
||||
isrcBuildingMu sync.Map // Per-directory build lock to prevent concurrent builds
|
||||
isrcIndexTTL = 5 * time.Minute
|
||||
|
||||
isrcIndexBuildWorkers = 4
|
||||
)
|
||||
|
||||
func (idx *ISRCIndex) isFresh() bool {
|
||||
return time.Since(time.Unix(0, idx.buildTime.Load())) < isrcIndexTTL
|
||||
}
|
||||
|
||||
func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||
isrcIndexCacheMu.RLock()
|
||||
idx, exists := isrcIndexCache[outputDir]
|
||||
isrcIndexCacheMu.RUnlock()
|
||||
|
||||
if exists && idx.isFresh() {
|
||||
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
||||
return idx
|
||||
}
|
||||
|
||||
@@ -60,7 +42,7 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||
idx, exists = isrcIndexCache[outputDir]
|
||||
isrcIndexCacheMu.RUnlock()
|
||||
|
||||
if exists && idx.isFresh() {
|
||||
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
||||
return idx
|
||||
}
|
||||
|
||||
@@ -70,37 +52,16 @@ func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||
func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||
idx := &ISRCIndex{
|
||||
index: make(map[string]string),
|
||||
files: make(map[string]isrcFileEntry),
|
||||
outputDir: outputDir,
|
||||
buildTime: time.Now(),
|
||||
}
|
||||
idx.buildTime.Store(time.Now().UnixNano())
|
||||
|
||||
if outputDir == "" {
|
||||
return idx
|
||||
}
|
||||
|
||||
// Reuse the previous build's per-file cache: files whose size and mtime
|
||||
// are unchanged are adopted without touching their content, so a rebuild
|
||||
// is normally just a stat walk.
|
||||
prevFiles := map[string]isrcFileEntry{}
|
||||
isrcIndexCacheMu.RLock()
|
||||
if prev, ok := isrcIndexCache[outputDir]; ok {
|
||||
prev.mu.RLock()
|
||||
for path, entry := range prev.files {
|
||||
prevFiles[path] = entry
|
||||
}
|
||||
prev.mu.RUnlock()
|
||||
}
|
||||
isrcIndexCacheMu.RUnlock()
|
||||
|
||||
startTime := time.Now()
|
||||
type parseTask struct {
|
||||
path string
|
||||
size int64
|
||||
modTime int64
|
||||
}
|
||||
var toParse []parseTask
|
||||
reused := 0
|
||||
fileCount := 0
|
||||
|
||||
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
@@ -112,56 +73,18 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||
return nil
|
||||
}
|
||||
|
||||
size := info.Size()
|
||||
modTime := info.ModTime().UnixNano()
|
||||
if entry, ok := prevFiles[path]; ok && entry.size == size && entry.modTime == modTime {
|
||||
idx.files[path] = entry
|
||||
if entry.isrc != "" {
|
||||
idx.index[entry.isrc] = path
|
||||
}
|
||||
reused++
|
||||
metadata, err := ReadMetadata(path)
|
||||
if err != nil || metadata.ISRC == "" {
|
||||
return nil
|
||||
}
|
||||
toParse = append(toParse, parseTask{path: path, size: size, modTime: modTime})
|
||||
|
||||
idx.index[strings.ToUpper(metadata.ISRC)] = path
|
||||
fileCount++
|
||||
return nil
|
||||
})
|
||||
|
||||
if len(toParse) > 0 {
|
||||
// New/changed files: read only their Vorbis comment block, in
|
||||
// parallel. Embedded cover art (megabytes per file) is never loaded.
|
||||
isrcs := make([]string, len(toParse))
|
||||
workerCount := isrcIndexBuildWorkers
|
||||
if len(toParse) < workerCount {
|
||||
workerCount = len(toParse)
|
||||
}
|
||||
tasks := make(chan int)
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < workerCount; w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := range tasks {
|
||||
isrcs[i] = strings.ToUpper(readFlacISRC(toParse[i].path))
|
||||
}
|
||||
}()
|
||||
}
|
||||
for i := range toParse {
|
||||
tasks <- i
|
||||
}
|
||||
close(tasks)
|
||||
wg.Wait()
|
||||
|
||||
for i, task := range toParse {
|
||||
entry := isrcFileEntry{size: task.size, modTime: task.modTime, isrc: isrcs[i]}
|
||||
idx.files[task.path] = entry
|
||||
if entry.isrc != "" {
|
||||
idx.index[entry.isrc] = task.path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("[ISRCIndex] Built index for %s: %d files (%d parsed, %d cached) in %v\n",
|
||||
outputDir, len(idx.files), len(toParse), reused, time.Since(startTime).Round(time.Millisecond))
|
||||
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
||||
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
||||
|
||||
isrcIndexCacheMu.Lock()
|
||||
isrcIndexCache[outputDir] = idx
|
||||
@@ -170,78 +93,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||
return idx
|
||||
}
|
||||
|
||||
// readFlacISRC extracts the ISRC Vorbis comment from a FLAC file by walking
|
||||
// the metadata block headers and reading only the VORBIS_COMMENT payload;
|
||||
// picture and padding blocks are seeked past, never loaded. Returns "" when
|
||||
// the file is not FLAC or carries no ISRC tag.
|
||||
func readFlacISRC(path string) string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
magic := make([]byte, 4)
|
||||
if _, err := io.ReadFull(f, magic); err != nil || string(magic) != "fLaC" {
|
||||
return ""
|
||||
}
|
||||
|
||||
header := make([]byte, 4)
|
||||
for {
|
||||
if _, err := io.ReadFull(f, header); err != nil {
|
||||
return ""
|
||||
}
|
||||
last := header[0]&0x80 != 0
|
||||
blockType := header[0] & 0x7F
|
||||
length := int64(header[1])<<16 | int64(header[2])<<8 | int64(header[3])
|
||||
if blockType == 4 { // VORBIS_COMMENT
|
||||
if length > 16<<20 {
|
||||
return ""
|
||||
}
|
||||
payload := make([]byte, length)
|
||||
if _, err := io.ReadFull(f, payload); err != nil {
|
||||
return ""
|
||||
}
|
||||
return vorbisCommentISRC(payload)
|
||||
}
|
||||
if last {
|
||||
return ""
|
||||
}
|
||||
if _, err := f.Seek(length, io.SeekCurrent); err != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func vorbisCommentISRC(payload []byte) string {
|
||||
if len(payload) < 8 {
|
||||
return ""
|
||||
}
|
||||
offset := int(binary.LittleEndian.Uint32(payload[0:4])) + 4
|
||||
if offset < 4 || offset+4 > len(payload) {
|
||||
return ""
|
||||
}
|
||||
count := int(binary.LittleEndian.Uint32(payload[offset : offset+4]))
|
||||
offset += 4
|
||||
for i := 0; i < count; i++ {
|
||||
if offset+4 > len(payload) {
|
||||
return ""
|
||||
}
|
||||
commentLen := int(binary.LittleEndian.Uint32(payload[offset : offset+4]))
|
||||
offset += 4
|
||||
if commentLen < 0 || offset+commentLen > len(payload) {
|
||||
return ""
|
||||
}
|
||||
comment := payload[offset : offset+commentLen]
|
||||
offset += commentLen
|
||||
eq := strings.IndexByte(string(comment), '=')
|
||||
if eq > 0 && strings.EqualFold(string(comment[:eq]), "ISRC") {
|
||||
return strings.TrimSpace(string(comment[eq+1:]))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
||||
if isrc == "" {
|
||||
return "", false
|
||||
@@ -275,30 +126,10 @@ func (idx *ISRCIndex) Add(isrc, filePath string) {
|
||||
return
|
||||
}
|
||||
|
||||
upper := strings.ToUpper(isrc)
|
||||
var entry *isrcFileEntry
|
||||
if info, err := os.Stat(filePath); err == nil {
|
||||
entry = &isrcFileEntry{
|
||||
size: info.Size(),
|
||||
modTime: info.ModTime().UnixNano(),
|
||||
isrc: upper,
|
||||
}
|
||||
}
|
||||
|
||||
idx.mu.Lock()
|
||||
idx.index[upper] = filePath
|
||||
if entry != nil {
|
||||
if idx.files == nil {
|
||||
idx.files = make(map[string]isrcFileEntry)
|
||||
}
|
||||
idx.files[filePath] = *entry
|
||||
}
|
||||
idx.mu.Unlock()
|
||||
defer idx.mu.Unlock()
|
||||
|
||||
// The index is write-maintained after every successful download;
|
||||
// refreshing the timestamp keeps the TTL from forcing a full rebuild in
|
||||
// the middle of the exact workload the index exists to serve.
|
||||
idx.buildTime.Store(time.Now().UnixNano())
|
||||
idx.index[strings.ToUpper(isrc)] = filePath
|
||||
}
|
||||
|
||||
func InvalidateISRCCache(outputDir string) {
|
||||
@@ -362,25 +193,35 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
|
||||
|
||||
isrcIdx := GetISRCIndex(outputDir)
|
||||
|
||||
// A lookup is a single map read. Holding one read lock for the batch avoids
|
||||
// one goroutine and one lock/unlock pair per track, which was slower and
|
||||
// could create thousands of goroutines for large playlists.
|
||||
isrcIdx.mu.RLock()
|
||||
var wg sync.WaitGroup
|
||||
for i, track := range tracks {
|
||||
result := FileExistenceResult{
|
||||
ISRC: track.ISRC,
|
||||
TrackName: track.TrackName,
|
||||
ArtistName: track.ArtistName,
|
||||
}
|
||||
if track.ISRC != "" {
|
||||
if filePath, exists := isrcIdx.index[strings.ToUpper(track.ISRC)]; exists {
|
||||
result.Exists = true
|
||||
result.FilePath = filePath
|
||||
wg.Add(1)
|
||||
go func(resultIdx int, t struct {
|
||||
ISRC string `json:"isrc"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
}) {
|
||||
defer wg.Done()
|
||||
|
||||
result := FileExistenceResult{
|
||||
ISRC: t.ISRC,
|
||||
TrackName: t.TrackName,
|
||||
ArtistName: t.ArtistName,
|
||||
Exists: false,
|
||||
}
|
||||
}
|
||||
results[i] = result
|
||||
|
||||
if t.ISRC != "" {
|
||||
if filePath, exists := isrcIdx.lookup(t.ISRC); exists {
|
||||
result.Exists = true
|
||||
result.FilePath = filePath
|
||||
}
|
||||
}
|
||||
|
||||
results[resultIdx] = result
|
||||
}(i, track)
|
||||
}
|
||||
isrcIdx.mu.RUnlock()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
resultJSON, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
|
||||
+3918
-51
File diff suppressed because it is too large
Load Diff
@@ -1,214 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetTrackCacheSize and ClearTrackIDCache back the Settings cache screen. The
|
||||
// track-ID cache is currently a no-op, so these report an empty cache and clear
|
||||
// nothing, but the gomobile export contract is kept for the Dart/Kotlin callers.
|
||||
func GetTrackCacheSize() int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func ClearTrackIDCache() {
|
||||
}
|
||||
|
||||
func GetDeezerRelatedArtists(artistID string, limit int) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := GetDeezerClient()
|
||||
artists, err := client.GetRelatedArtists(ctx, artistID, limit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"artists": artists,
|
||||
}
|
||||
return marshalJSONString(resp)
|
||||
}
|
||||
|
||||
func GetDeezerMetadata(resourceType, resourceID string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := GetDeezerClient()
|
||||
var data any
|
||||
var err error
|
||||
|
||||
switch resourceType {
|
||||
case "track":
|
||||
data, err = client.GetTrack(ctx, resourceID)
|
||||
case "album":
|
||||
data, err = client.GetAlbum(ctx, resourceID)
|
||||
case "artist":
|
||||
data, err = client.GetArtist(ctx, resourceID)
|
||||
case "playlist":
|
||||
data, err = client.GetPlaylist(ctx, resourceID)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported Deezer resource type: %s", resourceType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return marshalJSONString(data)
|
||||
}
|
||||
|
||||
func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
||||
if trackID == "" {
|
||||
return "", fmt.Errorf("empty track ID")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := GetDeezerClient()
|
||||
metadata, err := client.GetExtendedMetadataByTrackID(ctx, trackID)
|
||||
if err != nil {
|
||||
GoLog("[Deezer] Failed to get extended metadata: %v\n", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := buildDeezerExtendedMetadataResult(metadata)
|
||||
|
||||
return marshalJSONString(result)
|
||||
}
|
||||
|
||||
func SearchDeezerByISRC(isrc string) (string, error) {
|
||||
return SearchDeezerByISRCForItemID(isrc, "")
|
||||
}
|
||||
|
||||
func SearchDeezerByISRCForItemID(isrc string, itemID string) (string, error) {
|
||||
parentCtx := context.Background()
|
||||
if itemID != "" {
|
||||
parentCtx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return "", ErrDownloadCancelled
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := GetDeezerClient()
|
||||
track, err := client.SearchByISRC(ctx, isrc)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return "", ErrDownloadCancelled
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if isDownloadCancelled(itemID) {
|
||||
return "", ErrDownloadCancelled
|
||||
}
|
||||
|
||||
result := buildDeezerISRCSearchResult(track)
|
||||
return marshalJSONString(result)
|
||||
}
|
||||
|
||||
func buildDeezerExtendedMetadataResult(metadata *AlbumExtendedMetadata) map[string]string {
|
||||
if metadata == nil {
|
||||
return map[string]string{
|
||||
"genre": "",
|
||||
"label": "",
|
||||
"copyright": "",
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]string{
|
||||
"genre": metadata.Genre,
|
||||
"label": metadata.Label,
|
||||
"copyright": metadata.Copyright,
|
||||
}
|
||||
}
|
||||
|
||||
func buildDeezerISRCSearchResult(track *TrackMetadata) map[string]any {
|
||||
if track == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"spotify_id": track.SpotifyID,
|
||||
"artists": track.Artists,
|
||||
"name": track.Name,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": track.Images,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"total_discs": track.TotalDiscs,
|
||||
"external_urls": track.ExternalURL,
|
||||
"isrc": track.ISRC,
|
||||
"album_id": track.AlbumID,
|
||||
"artist_id": track.ArtistID,
|
||||
"album_type": track.AlbumType,
|
||||
"composer": track.Composer,
|
||||
}
|
||||
|
||||
if deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:")); deezerID != "" {
|
||||
result["id"] = deezerID
|
||||
result["track_id"] = deezerID
|
||||
result["success"] = true
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
songlink := NewSongLinkClient()
|
||||
deezerClient := GetDeezerClient()
|
||||
|
||||
if resourceType == "track" {
|
||||
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
|
||||
}
|
||||
|
||||
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
|
||||
}
|
||||
|
||||
return marshalJSONString(trackResp)
|
||||
}
|
||||
|
||||
if resourceType == "album" {
|
||||
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not find Deezer album: %w", err)
|
||||
}
|
||||
|
||||
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
|
||||
}
|
||||
|
||||
return marshalJSONString(albumResp)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("spotify to Deezer conversion only supported for tracks and albums: please search by name for %s", resourceType)
|
||||
}
|
||||
|
||||
func GetSpotifyIDFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
return client.GetSpotifyIDFromDeezer(deezerTrackID)
|
||||
}
|
||||
|
||||
func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||
client := NewSongLinkClient()
|
||||
return client.GetTidalURLFromDeezer(deezerTrackID)
|
||||
}
|
||||
@@ -1,493 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DownloadRequest struct {
|
||||
ContractVersion int `json:"contract_version,omitempty"`
|
||||
ISRC string `json:"isrc"`
|
||||
Service string `json:"service"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
AlbumArtist string `json:"album_artist"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
OutputDir string `json:"output_dir"`
|
||||
OutputPath string `json:"output_path,omitempty"`
|
||||
OutputFD int `json:"output_fd,omitempty"`
|
||||
OutputExt string `json:"output_ext,omitempty"`
|
||||
FilenameFormat string `json:"filename_format"`
|
||||
Quality string `json:"quality"`
|
||||
EmbedMetadata bool `json:"embed_metadata"`
|
||||
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
|
||||
EmbedReplayGain bool `json:"embed_replaygain,omitempty"`
|
||||
PostProcessingEnabled bool `json:"post_processing_enabled,omitempty"`
|
||||
TidalHighFormat string `json:"tidal_high_format,omitempty"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
PlaylistPosition int `json:"playlist_position,omitempty"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ItemID string `json:"item_id"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
Source string `json:"source"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||
UseExtensions bool `json:"use_extensions,omitempty"`
|
||||
UseFallback bool `json:"use_fallback,omitempty"`
|
||||
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
|
||||
AllowQualityVariant bool `json:"allow_quality_variant,omitempty"`
|
||||
QualityVariant string `json:"quality_variant,omitempty"`
|
||||
SongLinkRegion string `json:"songlink_region,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"`
|
||||
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||
AudioCodec string `json:"audio_codec,omitempty"`
|
||||
ActualExtension string `json:"actual_extension,omitempty"`
|
||||
ActualContainer string `json:"actual_container,omitempty"`
|
||||
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
|
||||
Service string `json:"service,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||
LyricsLRC string `json:"lyrics_lrc,omitempty"`
|
||||
DecryptionKey string `json:"decryption_key,omitempty"`
|
||||
Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
AudioCodec string
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
TotalTracks int
|
||||
DiscNumber int
|
||||
TotalDiscs int
|
||||
ISRC string
|
||||
CoverURL string
|
||||
Genre string
|
||||
Label string
|
||||
Copyright string
|
||||
Composer string
|
||||
LyricsLRC string
|
||||
DecryptionKey string
|
||||
Decryption *DownloadDecryptionInfo
|
||||
ActualExtension string
|
||||
ActualContainer string
|
||||
RequiresContainerConversion bool
|
||||
}
|
||||
|
||||
func buildDownloadSuccessResponse(
|
||||
req DownloadRequest,
|
||||
result DownloadResult,
|
||||
service string,
|
||||
message string,
|
||||
filePath string,
|
||||
alreadyExists bool,
|
||||
) DownloadResponse {
|
||||
title := result.Title
|
||||
if title == "" {
|
||||
title = req.TrackName
|
||||
}
|
||||
|
||||
artist := result.Artist
|
||||
if artist == "" {
|
||||
artist = req.ArtistName
|
||||
}
|
||||
|
||||
// Preserve requested release metadata when available so mixed-provider
|
||||
// fallback downloads from the same source album do not get split into
|
||||
// different albums just because Tidal/Qobuz report variant titles/dates.
|
||||
album, releaseDate, trackNumber, discNumber := preferredReleaseMetadata(
|
||||
req,
|
||||
result.Album,
|
||||
result.ReleaseDate,
|
||||
result.TrackNumber,
|
||||
result.DiscNumber,
|
||||
)
|
||||
|
||||
isrc := result.ISRC
|
||||
if isrc == "" {
|
||||
isrc = req.ISRC
|
||||
}
|
||||
|
||||
genre := result.Genre
|
||||
if genre == "" {
|
||||
genre = req.Genre
|
||||
}
|
||||
|
||||
label := result.Label
|
||||
if label == "" {
|
||||
label = req.Label
|
||||
}
|
||||
|
||||
copyright := result.Copyright
|
||||
if copyright == "" {
|
||||
copyright = req.Copyright
|
||||
}
|
||||
|
||||
composer := result.Composer
|
||||
if composer == "" {
|
||||
composer = req.Composer
|
||||
}
|
||||
|
||||
coverURL := strings.TrimSpace(result.CoverURL)
|
||||
if coverURL == "" {
|
||||
coverURL = strings.TrimSpace(req.CoverURL)
|
||||
}
|
||||
|
||||
return DownloadResponse{
|
||||
Success: true,
|
||||
Message: message,
|
||||
FilePath: filePath,
|
||||
AlreadyExists: alreadyExists,
|
||||
ActualBitDepth: result.BitDepth,
|
||||
ActualSampleRate: result.SampleRate,
|
||||
AudioCodec: result.AudioCodec,
|
||||
ActualExtension: result.ActualExtension,
|
||||
ActualContainer: result.ActualContainer,
|
||||
RequiresContainerConversion: result.RequiresContainerConversion,
|
||||
Service: service,
|
||||
Title: title,
|
||||
Artist: artist,
|
||||
Album: album,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
ReleaseDate: releaseDate,
|
||||
TrackNumber: trackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: discNumber,
|
||||
TotalDiscs: req.TotalDiscs,
|
||||
ISRC: isrc,
|
||||
CoverURL: coverURL,
|
||||
Genre: genre,
|
||||
Label: label,
|
||||
Copyright: copyright,
|
||||
Composer: composer,
|
||||
LyricsLRC: result.LyricsLRC,
|
||||
DecryptionKey: result.DecryptionKey,
|
||||
Decryption: normalizeDownloadDecryptionInfo(result.Decryption, result.DecryptionKey),
|
||||
}
|
||||
}
|
||||
|
||||
func shouldSkipQualityProbe(filePath string) bool {
|
||||
path := strings.TrimSpace(filePath)
|
||||
if path == "" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(path, "/proc/self/fd/") {
|
||||
return true
|
||||
}
|
||||
// Content URI and other non-filesystem schemes cannot be read directly by os.Open.
|
||||
if strings.Contains(path, "://") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func enrichResultQualityFromFile(result *DownloadResult) {
|
||||
if result == nil {
|
||||
return
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(result.FilePath)
|
||||
if shouldSkipQualityProbe(path) {
|
||||
if strings.HasPrefix(path, "/proc/self/fd/") {
|
||||
LogDebug("Download", "Skipping quality probe for ephemeral SAF FD output: %s", path)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
quality, qErr := GetAudioQuality(path)
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
result.AudioCodec = quality.Codec
|
||||
if quality.Codec != "" {
|
||||
GoLog("[Download] Actual quality from file: %s %d-bit/%dHz\n", quality.Codec, quality.BitDepth, quality.SampleRate)
|
||||
} else {
|
||||
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LogDebug("Download", "Post-download quality probe unavailable for %s: %v", path, qErr)
|
||||
}
|
||||
|
||||
func applyExtendedMetadataFields(
|
||||
genre *string,
|
||||
label *string,
|
||||
copyright *string,
|
||||
extMeta *AlbumExtendedMetadata,
|
||||
) {
|
||||
if extMeta == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if genre != nil && *genre == "" && extMeta.Genre != "" {
|
||||
*genre = extMeta.Genre
|
||||
}
|
||||
if label != nil && *label == "" && extMeta.Label != "" {
|
||||
*label = extMeta.Label
|
||||
}
|
||||
if copyright != nil && *copyright == "" && extMeta.Copyright != "" {
|
||||
*copyright = extMeta.Copyright
|
||||
}
|
||||
}
|
||||
|
||||
func enrichExtraMetadataByISRC(
|
||||
logPrefix string,
|
||||
isrc string,
|
||||
genre *string,
|
||||
label *string,
|
||||
copyright *string,
|
||||
) {
|
||||
normalizedISRC := strings.TrimSpace(isrc)
|
||||
if normalizedISRC == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
extMeta, err := fetchDeezerExtendedMetadataByISRC(ctx, normalizedISRC)
|
||||
if err != nil {
|
||||
GoLog("[%s] Failed to get extended metadata from Deezer: %v\n", logPrefix, err)
|
||||
}
|
||||
applyExtendedMetadataFields(genre, label, copyright, extMeta)
|
||||
|
||||
if genre != nil && *genre == "" {
|
||||
musicBrainzGenre, err := fetchMusicBrainzGenreByISRC(normalizedISRC)
|
||||
if err != nil {
|
||||
GoLog("[%s] Failed to get genre from MusicBrainz: %v\n", logPrefix, err)
|
||||
} else if musicBrainzGenre != "" {
|
||||
*genre = musicBrainzGenre
|
||||
GoLog("[%s] Genre fallback from MusicBrainz: %s\n", logPrefix, *genre)
|
||||
}
|
||||
}
|
||||
|
||||
currentGenre := ""
|
||||
currentLabel := ""
|
||||
currentCopyright := ""
|
||||
if genre != nil {
|
||||
currentGenre = *genre
|
||||
}
|
||||
if label != nil {
|
||||
currentLabel = *label
|
||||
}
|
||||
if copyright != nil {
|
||||
currentCopyright = *copyright
|
||||
}
|
||||
if currentGenre != "" || currentLabel != "" || currentCopyright != "" {
|
||||
GoLog("[%s] Extended metadata ready: genre=%s, label=%s, copyright=%s\n", logPrefix, currentGenre, currentLabel, currentCopyright)
|
||||
}
|
||||
}
|
||||
|
||||
func enrichRequestExtendedMetadata(req *DownloadRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.ISRC == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.AlbumArtist) == "" {
|
||||
albumArtist, err := fetchMusicBrainzAlbumArtistByISRC(req.ISRC, req.AlbumName)
|
||||
if err != nil {
|
||||
GoLog("[DownloadWithFallback] Failed to get album artist from MusicBrainz: %v\n", err)
|
||||
} else if strings.TrimSpace(albumArtist) != "" {
|
||||
req.AlbumArtist = strings.TrimSpace(albumArtist)
|
||||
GoLog("[DownloadWithFallback] Album artist fallback from MusicBrainz: %s\n", req.AlbumArtist)
|
||||
}
|
||||
}
|
||||
|
||||
if req.Genre == "" || req.Label == "" || req.Copyright == "" {
|
||||
enrichExtraMetadataByISRC(
|
||||
"DownloadWithFallback",
|
||||
req.ISRC,
|
||||
&req.Genre,
|
||||
&req.Label,
|
||||
&req.Copyright,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func applySongLinkRegionFromRequest(req *DownloadRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
SetSongLinkRegion(req.SongLinkRegion)
|
||||
}
|
||||
|
||||
// DownloadByStrategy routes all download requests through extension providers.
|
||||
func DownloadByStrategy(requestJSON string) (string, error) {
|
||||
var req DownloadRequest
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
normalizedBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return errorResponse("Invalid request: " + err.Error())
|
||||
}
|
||||
normalizedJSON := string(normalizedBytes)
|
||||
|
||||
if req.UseExtensions {
|
||||
resp, err := DownloadWithExtensionsJSON(normalizedJSON)
|
||||
if err != nil {
|
||||
return errorResponse(err.Error())
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
return errorResponse("Extension providers are disabled; built-in download providers have been retired")
|
||||
}
|
||||
|
||||
func GetDownloadProgress() string {
|
||||
progress := getProgress()
|
||||
jsonBytes, _ := json.Marshal(progress)
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
func GetAllDownloadProgress() string {
|
||||
return GetMultiProgress()
|
||||
}
|
||||
|
||||
func GetAllDownloadProgressDelta(sinceSeq int64) string {
|
||||
return GetMultiProgressDelta(sinceSeq)
|
||||
}
|
||||
|
||||
func InitItemProgress(itemID string) {
|
||||
StartItemProgress(itemID)
|
||||
}
|
||||
|
||||
func FinishItemProgress(itemID string) {
|
||||
CompleteItemProgress(itemID)
|
||||
}
|
||||
|
||||
func ClearItemProgress(itemID string) {
|
||||
RemoveItemProgress(itemID)
|
||||
}
|
||||
|
||||
func CancelDownload(itemID string) {
|
||||
cancelDownload(itemID)
|
||||
}
|
||||
|
||||
// ResetDownloadCancel drops a pre-registered cancellation flag for an item
|
||||
// with no active download, so a user-initiated retry does not consume a stale
|
||||
// cancel and abort instantly. Entries with live references are left alone.
|
||||
func ResetDownloadCancel(itemID string) {
|
||||
resetDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
func CleanupConnections() {
|
||||
CloseIdleConnections()
|
||||
}
|
||||
|
||||
func errorResponse(msg string) (string, error) {
|
||||
errorType := classifyDownloadErrorType(msg)
|
||||
|
||||
resp := DownloadResponse{
|
||||
Success: false,
|
||||
Error: msg,
|
||||
ErrorType: errorType,
|
||||
}
|
||||
s, _ := marshalJSONString(resp)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func classifyDownloadErrorType(msg string) string {
|
||||
lowerMsg := strings.ToLower(msg)
|
||||
|
||||
if strings.Contains(lowerMsg, "isp blocking") ||
|
||||
strings.Contains(lowerMsg, "try using vpn") ||
|
||||
strings.Contains(lowerMsg, "change dns") {
|
||||
return "isp_blocked"
|
||||
} else if strings.Contains(lowerMsg, "cancel") {
|
||||
return "cancelled"
|
||||
} else if strings.Contains(lowerMsg, "verify_required") ||
|
||||
strings.Contains(lowerMsg, "verification_required") ||
|
||||
strings.Contains(lowerMsg, "verification required") ||
|
||||
strings.Contains(lowerMsg, "needs verification") ||
|
||||
strings.Contains(lowerMsg, "session is not authenticated") ||
|
||||
strings.Contains(lowerMsg, "signed session is not authenticated") ||
|
||||
strings.Contains(lowerMsg, "signed session expired") ||
|
||||
strings.Contains(lowerMsg, "unauthorized") ||
|
||||
strings.Contains(lowerMsg, "precondition required") ||
|
||||
messageHasHTTPStatusCode(lowerMsg, "401") ||
|
||||
messageHasHTTPStatusCode(lowerMsg, "428") {
|
||||
return "verification_required"
|
||||
} else if strings.Contains(lowerMsg, "rate limit") ||
|
||||
messageHasHTTPStatusCode(lowerMsg, "429") ||
|
||||
strings.Contains(lowerMsg, "too many requests") {
|
||||
return "rate_limit"
|
||||
} else if strings.Contains(lowerMsg, "permission") ||
|
||||
strings.Contains(lowerMsg, "operation not permitted") ||
|
||||
strings.Contains(lowerMsg, "access denied") ||
|
||||
strings.Contains(lowerMsg, "failed to create file") ||
|
||||
strings.Contains(lowerMsg, "failed to create directory") {
|
||||
return "permission"
|
||||
} else if strings.Contains(lowerMsg, "not found") ||
|
||||
strings.Contains(lowerMsg, "not available") ||
|
||||
strings.Contains(lowerMsg, "no results") ||
|
||||
strings.Contains(lowerMsg, "track not found") ||
|
||||
strings.Contains(lowerMsg, "all services failed") {
|
||||
return "not_found"
|
||||
} else if strings.Contains(lowerMsg, "network") ||
|
||||
strings.Contains(lowerMsg, "connection") ||
|
||||
strings.Contains(lowerMsg, "timeout") ||
|
||||
strings.Contains(lowerMsg, "dial") {
|
||||
return "network"
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func messageHasHTTPStatusCode(lowerMsg, code string) bool {
|
||||
return strings.Contains(lowerMsg, "http "+code) ||
|
||||
strings.Contains(lowerMsg, "http status "+code) ||
|
||||
strings.Contains(lowerMsg, "status "+code) ||
|
||||
strings.Contains(lowerMsg, code+" for ") ||
|
||||
strings.Contains(lowerMsg, code+":") ||
|
||||
strings.Contains(lowerMsg, code+";")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,33 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
func SetLibraryCoverCacheDirJSON(cacheDir string) {
|
||||
SetLibraryCoverCacheDir(cacheDir)
|
||||
}
|
||||
|
||||
func ScanLibraryFolderJSON(folderPath string) (string, error) {
|
||||
return ScanLibraryFolder(folderPath)
|
||||
}
|
||||
|
||||
func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) {
|
||||
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
|
||||
}
|
||||
|
||||
func ScanLibraryFolderIncrementalFromSnapshotJSON(folderPath, snapshotPath string) (string, error) {
|
||||
return ScanLibraryFolderIncrementalFromSnapshot(folderPath, snapshotPath)
|
||||
}
|
||||
|
||||
func GetLibraryScanProgressJSON() string {
|
||||
return GetLibraryScanProgress()
|
||||
}
|
||||
|
||||
func CancelLibraryScanJSON() {
|
||||
CancelLibraryScan()
|
||||
}
|
||||
|
||||
func ReadAudioMetadataJSON(filePath string) (string, error) {
|
||||
return ReadAudioMetadata(filePath)
|
||||
}
|
||||
|
||||
func ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filePath, displayName, coverCacheKey string) (string, error) {
|
||||
return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayName, coverCacheKey)
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) {
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"success": true,
|
||||
"source": lyrics.Source,
|
||||
"sync_type": lyrics.SyncType,
|
||||
"lines": lyrics.Lines,
|
||||
"instrumental": lyrics.Instrumental,
|
||||
}
|
||||
|
||||
return marshalJSONString(result)
|
||||
}
|
||||
|
||||
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
||||
if filePath != "" {
|
||||
lyrics, err := ExtractLyrics(filePath)
|
||||
if err == nil && lyrics != "" {
|
||||
return lyrics, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if lyricsData.Instrumental {
|
||||
return "[instrumental:true]", nil
|
||||
}
|
||||
|
||||
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||
return lrcContent, nil
|
||||
}
|
||||
|
||||
func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
||||
if filePath != "" {
|
||||
lyrics, err := ExtractLyrics(filePath)
|
||||
if err == nil && lyrics != "" {
|
||||
source := extractLyricsSourceFromLRC(lyrics)
|
||||
if source == "" {
|
||||
source = "Embedded"
|
||||
}
|
||||
result := map[string]any{
|
||||
"lyrics": lyrics,
|
||||
"source": source,
|
||||
"sync_type": "EMBEDDED",
|
||||
"instrumental": false,
|
||||
}
|
||||
return marshalJSONString(result)
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"lyrics": "",
|
||||
"source": "",
|
||||
"sync_type": "",
|
||||
"instrumental": false,
|
||||
}
|
||||
return marshalJSONString(result)
|
||||
}
|
||||
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
lrcContent := ""
|
||||
if lyricsData.Instrumental {
|
||||
lrcContent = "[instrumental:true]"
|
||||
} else {
|
||||
lrcContent = convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"lyrics": lrcContent,
|
||||
"source": lyricsData.Source,
|
||||
"sync_type": lyricsData.SyncType,
|
||||
"instrumental": lyricsData.Instrumental,
|
||||
}
|
||||
return marshalJSONString(result)
|
||||
}
|
||||
|
||||
func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
|
||||
err := EmbedLyrics(filePath, lyrics)
|
||||
if err != nil {
|
||||
return errorResponse("Failed to embed lyrics: " + err.Error())
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"message": "Lyrics embedded successfully",
|
||||
}
|
||||
|
||||
s, _ := marshalJSONString(resp)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int64, outputPath string, audioFilePath string) error {
|
||||
// If the audio file already has embedded lyrics or a sidecar .lrc,
|
||||
// use those directly instead of making redundant network requests.
|
||||
if audioFilePath != "" {
|
||||
existing, err := ExtractLyrics(audioFilePath)
|
||||
if err == nil && strings.TrimSpace(existing) != "" {
|
||||
if err := os.WriteFile(outputPath, []byte(existing), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write LRC file: %w", err)
|
||||
}
|
||||
GoLog("[Lyrics] Saved LRC from embedded/sidecar to: %s\n", outputPath)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(durationMs) / 1000.0
|
||||
|
||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lyrics not found: %w", err)
|
||||
}
|
||||
|
||||
if lyrics.Instrumental {
|
||||
return fmt.Errorf("track is instrumental, no lyrics available")
|
||||
}
|
||||
|
||||
lrcContent := convertToLRCWithMetadata(lyrics, trackName, artistName)
|
||||
if lrcContent == "" {
|
||||
return fmt.Errorf("failed to generate LRC content")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outputPath, []byte(lrcContent), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write LRC file: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[Lyrics] Saved LRC to: %s (%d lines)\n", outputPath, len(lyrics.Lines))
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetLyricsProvidersJSON(providersJSON string) error {
|
||||
var providers []string
|
||||
if err := json.Unmarshal([]byte(providersJSON), &providers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
SetLyricsProviderOrder(providers)
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetLyricsProvidersJSON() (string, error) {
|
||||
providers := GetLyricsProviderOrder()
|
||||
return marshalJSONString(providers)
|
||||
}
|
||||
|
||||
func GetAvailableLyricsProvidersJSON() (string, error) {
|
||||
providers := GetAvailableLyricsProviders()
|
||||
return marshalJSONString(providers)
|
||||
}
|
||||
|
||||
func SetLyricsFetchOptionsJSON(optionsJSON string) error {
|
||||
opts := GetLyricsFetchOptions()
|
||||
if strings.TrimSpace(optionsJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(optionsJSON), &opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
SetLyricsFetchOptions(opts)
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetLyricsFetchOptionsJSON() (string, error) {
|
||||
opts := GetLyricsFetchOptions()
|
||||
return marshalJSONString(opts)
|
||||
}
|
||||
@@ -1,572 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func applyAudioMetadataToResult(result map[string]any, meta *AudioMetadata) {
|
||||
result["title"] = meta.Title
|
||||
result["artist"] = meta.Artist
|
||||
result["album"] = meta.Album
|
||||
result["album_artist"] = meta.AlbumArtist
|
||||
result["date"] = meta.Date
|
||||
if meta.Date == "" {
|
||||
result["date"] = meta.Year
|
||||
}
|
||||
result["track_number"] = meta.TrackNumber
|
||||
result["total_tracks"] = meta.TotalTracks
|
||||
result["disc_number"] = meta.DiscNumber
|
||||
result["total_discs"] = meta.TotalDiscs
|
||||
result["isrc"] = meta.ISRC
|
||||
result["lyrics"] = meta.Lyrics
|
||||
result["genre"] = meta.Genre
|
||||
result["label"] = meta.Label
|
||||
result["copyright"] = meta.Copyright
|
||||
result["composer"] = meta.Composer
|
||||
result["comment"] = meta.Comment
|
||||
result["replaygain_track_gain"] = meta.ReplayGainTrackGain
|
||||
result["replaygain_track_peak"] = meta.ReplayGainTrackPeak
|
||||
result["replaygain_album_gain"] = meta.ReplayGainAlbumGain
|
||||
result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak
|
||||
}
|
||||
|
||||
func successMethodJSON(method string) (string, error) {
|
||||
return marshalJSONString(map[string]any{"success": true, "method": method})
|
||||
}
|
||||
|
||||
func ReadFileMetadata(filePath string) (string, error) {
|
||||
lower := strings.ToLower(filePath)
|
||||
isFlac := strings.HasSuffix(lower, ".flac")
|
||||
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".aac")
|
||||
isMp3 := strings.HasSuffix(lower, ".mp3")
|
||||
isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
|
||||
isApe := strings.HasSuffix(lower, ".ape")
|
||||
isWv := strings.HasSuffix(lower, ".wv")
|
||||
isMpc := strings.HasSuffix(lower, ".mpc")
|
||||
isWav := strings.HasSuffix(lower, ".wav")
|
||||
isAiff := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
|
||||
|
||||
result := map[string]any{
|
||||
"title": "",
|
||||
"artist": "",
|
||||
"album": "",
|
||||
"album_artist": "",
|
||||
"date": "",
|
||||
"track_number": 0,
|
||||
"total_tracks": 0,
|
||||
"disc_number": 0,
|
||||
"total_discs": 0,
|
||||
"isrc": "",
|
||||
"lyrics": "",
|
||||
"genre": "",
|
||||
"label": "",
|
||||
"copyright": "",
|
||||
"composer": "",
|
||||
"comment": "",
|
||||
"duration": 0,
|
||||
"format": "",
|
||||
"audio_codec": "",
|
||||
}
|
||||
|
||||
if isFlac {
|
||||
result["format"] = "flac"
|
||||
result["audio_codec"] = "flac"
|
||||
metadata, err := ReadMetadata(filePath)
|
||||
if err != nil {
|
||||
// File may have wrong extension (e.g. opus saved as .flac).
|
||||
// Try Ogg/Opus parser as fallback before giving up.
|
||||
GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err)
|
||||
oggMeta, oggErr := ReadOggVorbisComments(filePath)
|
||||
if oggErr == nil && oggMeta != nil {
|
||||
result["title"] = oggMeta.Title
|
||||
result["artist"] = oggMeta.Artist
|
||||
result["album"] = oggMeta.Album
|
||||
result["album_artist"] = oggMeta.AlbumArtist
|
||||
result["date"] = oggMeta.Date
|
||||
if oggMeta.Date == "" {
|
||||
result["date"] = oggMeta.Year
|
||||
}
|
||||
result["track_number"] = oggMeta.TrackNumber
|
||||
result["total_tracks"] = oggMeta.TotalTracks
|
||||
result["disc_number"] = oggMeta.DiscNumber
|
||||
result["total_discs"] = oggMeta.TotalDiscs
|
||||
result["isrc"] = oggMeta.ISRC
|
||||
result["lyrics"] = oggMeta.Lyrics
|
||||
result["genre"] = oggMeta.Genre
|
||||
result["composer"] = oggMeta.Composer
|
||||
result["comment"] = oggMeta.Comment
|
||||
quality, qualityErr := GetOggQuality(filePath)
|
||||
if qualityErr == nil {
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
result["duration"] = quality.Duration
|
||||
if quality.Bitrate > 0 {
|
||||
result["bitrate"] = quality.Bitrate / 1000
|
||||
}
|
||||
}
|
||||
result["format"] = "opus"
|
||||
result["audio_codec"] = "opus"
|
||||
} else {
|
||||
return "", fmt.Errorf("failed to read metadata: %w", err)
|
||||
}
|
||||
} else {
|
||||
result["title"] = metadata.Title
|
||||
result["artist"] = metadata.Artist
|
||||
result["album"] = metadata.Album
|
||||
result["album_artist"] = metadata.AlbumArtist
|
||||
result["date"] = metadata.Date
|
||||
result["track_number"] = metadata.TrackNumber
|
||||
result["total_tracks"] = metadata.TotalTracks
|
||||
result["disc_number"] = metadata.DiscNumber
|
||||
result["total_discs"] = metadata.TotalDiscs
|
||||
result["isrc"] = metadata.ISRC
|
||||
result["lyrics"] = metadata.Lyrics
|
||||
result["genre"] = metadata.Genre
|
||||
result["label"] = metadata.Label
|
||||
result["copyright"] = metadata.Copyright
|
||||
result["composer"] = metadata.Composer
|
||||
result["comment"] = metadata.Comment
|
||||
result["replaygain_track_gain"] = metadata.ReplayGainTrackGain
|
||||
result["replaygain_track_peak"] = metadata.ReplayGainTrackPeak
|
||||
result["replaygain_album_gain"] = metadata.ReplayGainAlbumGain
|
||||
result["replaygain_album_peak"] = metadata.ReplayGainAlbumPeak
|
||||
|
||||
quality, qualityErr := GetAudioQuality(filePath)
|
||||
if qualityErr == nil {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
if quality.Codec != "" {
|
||||
result["audio_codec"] = quality.Codec
|
||||
}
|
||||
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if isM4A {
|
||||
result["format"] = "m4a"
|
||||
meta, err := ReadM4ATags(filePath)
|
||||
if err == nil && meta != nil {
|
||||
applyAudioMetadataToResult(result, meta)
|
||||
}
|
||||
quality, qualityErr := GetM4AQuality(filePath)
|
||||
if qualityErr == nil {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
result["duration"] = quality.Duration
|
||||
result["audio_codec"] = quality.Codec
|
||||
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
|
||||
result["format"] = format
|
||||
}
|
||||
if quality.Bitrate > 0 && !isLosslessLibraryFormat(fmt.Sprint(result["format"])) {
|
||||
result["bitrate"] = quality.Bitrate
|
||||
}
|
||||
}
|
||||
} else if isMp3 {
|
||||
result["format"] = "mp3"
|
||||
result["audio_codec"] = "mp3"
|
||||
meta, err := ReadID3Tags(filePath)
|
||||
if err == nil && meta != nil {
|
||||
applyAudioMetadataToResult(result, meta)
|
||||
}
|
||||
quality, qualityErr := GetMP3Quality(filePath)
|
||||
if qualityErr == nil {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
result["duration"] = quality.Duration
|
||||
if quality.Bitrate > 0 {
|
||||
result["bitrate"] = quality.Bitrate / 1000
|
||||
}
|
||||
}
|
||||
} else if isOgg {
|
||||
result["format"] = "opus"
|
||||
result["audio_codec"] = "opus"
|
||||
meta, err := ReadOggVorbisComments(filePath)
|
||||
if err == nil && meta != nil {
|
||||
applyAudioMetadataToResult(result, meta)
|
||||
}
|
||||
quality, qualityErr := GetOggQuality(filePath)
|
||||
if qualityErr == nil {
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
result["duration"] = quality.Duration
|
||||
if quality.Bitrate > 0 {
|
||||
result["bitrate"] = quality.Bitrate / 1000
|
||||
}
|
||||
}
|
||||
} else if isApe || isWv || isMpc {
|
||||
result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".")
|
||||
result["audio_codec"] = result["format"]
|
||||
apeTag, apeErr := ReadAPETags(filePath)
|
||||
if apeErr == nil && apeTag != nil {
|
||||
meta := APETagToAudioMetadata(apeTag)
|
||||
if meta != nil {
|
||||
applyAudioMetadataToResult(result, meta)
|
||||
}
|
||||
}
|
||||
} else if isWav || isAiff {
|
||||
var meta *AudioMetadata
|
||||
var quality *WAVQuality
|
||||
var qualityErr error
|
||||
if isAiff {
|
||||
result["format"] = "aiff"
|
||||
result["audio_codec"] = "pcm"
|
||||
meta, _ = ReadAIFFTags(filePath)
|
||||
quality, qualityErr = GetAIFFQuality(filePath)
|
||||
} else {
|
||||
result["format"] = "wav"
|
||||
result["audio_codec"] = "pcm"
|
||||
meta, _ = ReadWAVTags(filePath)
|
||||
quality, qualityErr = GetWAVQuality(filePath)
|
||||
}
|
||||
if meta != nil {
|
||||
applyAudioMetadataToResult(result, meta)
|
||||
}
|
||||
if qualityErr == nil && quality != nil {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
result["duration"] = quality.Duration
|
||||
}
|
||||
} else {
|
||||
return "", fmt.Errorf("unsupported file format: %s", filePath)
|
||||
}
|
||||
|
||||
return marshalJSONString(result)
|
||||
}
|
||||
|
||||
// ParseCueSheet is called from Dart to get track listing and timing data for CUE splitting.
|
||||
// audioDir, if non-empty, overrides the directory used for resolving the
|
||||
// referenced audio file (useful for SAF temp file scenarios).
|
||||
func ParseCueSheet(cuePath string, audioDir string) (string, error) {
|
||||
return ParseCueFileJSON(cuePath, audioDir)
|
||||
}
|
||||
|
||||
// ScanCueSheetForLibrary parses a .cue file and returns a JSON array of
|
||||
// LibraryScanResult entries (one per track). This is the SAF-friendly variant:
|
||||
// - audioDir overrides where the referenced audio file is resolved
|
||||
// - virtualPathPrefix replaces cuePath in filePath / id fields (e.g. a content:// URI)
|
||||
// - fileModTime is stamped on every result (pass 0 to stat cuePath instead)
|
||||
func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileModTime int64) (string, error) {
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
results, err := ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix, fileModTime, scanTime)
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
jsonBytes, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func ScanCueSheetForLibraryWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey string) (string, error) {
|
||||
scanTime := time.Now().UTC().Format(time.RFC3339)
|
||||
results, err := ScanCueFileForLibraryExtWithCoverCacheKey(
|
||||
cuePath,
|
||||
audioDir,
|
||||
virtualPathPrefix,
|
||||
fileModTime,
|
||||
coverCacheKey,
|
||||
scanTime,
|
||||
)
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
jsonBytes, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// WriteM4AFreeformTags writes ISRC and label into an M4A/MP4 file as iTunes
|
||||
// freeform atoms. FFmpeg's MP4 muxer ignores these keys, so they must be
|
||||
// written natively after the FFmpeg metadata pass for the values to persist.
|
||||
// Only keys present in the JSON are touched; an empty value clears the tag.
|
||||
func WriteM4AFreeformTags(filePath, metadataJSON string) (string, error) {
|
||||
var fields map[string]string
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil {
|
||||
return "", fmt.Errorf("invalid metadata JSON: %w", err)
|
||||
}
|
||||
|
||||
if err := EditM4AFreeformText(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write M4A freeform tags: %w", err)
|
||||
}
|
||||
|
||||
return successMethodJSON("native_m4a_freeform")
|
||||
}
|
||||
|
||||
// EnsureAC4Config normalizes a decrypted AC-4 file to a standards-compliant ISO
|
||||
// MP4 and injects the dac4 configuration box copied from sourcePath. No-op when
|
||||
// the file is not AC-4.
|
||||
func EnsureAC4Config(filePath, sourcePath string) (string, error) {
|
||||
if err := EnsureAC4ConfigBox(filePath, sourcePath); err != nil {
|
||||
return "", fmt.Errorf("failed to finalize AC-4 container: %w", err)
|
||||
}
|
||||
return `{"success":true}`, nil
|
||||
}
|
||||
|
||||
// WriteAC4Metadata writes iTunes-style metadata into an AC-4 MP4. The JSON
|
||||
// "handled" field reports whether the file was AC-4 (true) so the caller can
|
||||
// skip the FFmpeg metadata pass that would re-wrap it as QuickTime.
|
||||
func WriteAC4Metadata(filePath, metadataJSON, coverPath string) (string, error) {
|
||||
handled, err := WriteAC4MetadataIfApplicable(filePath, metadataJSON, coverPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to write AC-4 metadata: %w", err)
|
||||
}
|
||||
resp := map[string]any{"success": true, "handled": handled}
|
||||
s, _ := marshalJSONString(resp)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// EditFileMetadata writes audio file tags: FLAC via native Go library, MP3/Opus returns map for Dart/FFmpeg.
|
||||
func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
var fields map[string]string
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil {
|
||||
return "", fmt.Errorf("invalid metadata JSON: %w", err)
|
||||
}
|
||||
|
||||
lower := strings.ToLower(filePath)
|
||||
isFlac := strings.HasSuffix(lower, ".flac")
|
||||
isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc")
|
||||
isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b")
|
||||
isWavFile := strings.HasSuffix(lower, ".wav")
|
||||
isAiffFile := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
|
||||
coverPath := strings.TrimSpace(fields["cover_path"])
|
||||
|
||||
if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) {
|
||||
if err := EditM4AReplayGain(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write M4A metadata: %w", err)
|
||||
}
|
||||
|
||||
return successMethodJSON("native_m4a_replaygain")
|
||||
}
|
||||
|
||||
if isFlac {
|
||||
// A .flac name does not guarantee FLAC content: providers sometimes
|
||||
// deliver an MP4/M4A stream that ends up under the requested name.
|
||||
// The FLAC writer would fail "fLaC head incorrect" on every attempt,
|
||||
// so detect the mismatch up front and say what is actually wrong.
|
||||
if isMP4ContainerFile(filePath) {
|
||||
return "", fmt.Errorf(
|
||||
"failed to write FLAC metadata: file is an MP4/M4A stream under a .flac name; rename it to .m4a",
|
||||
)
|
||||
}
|
||||
if err := EditFlacFields(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
|
||||
}
|
||||
|
||||
return successMethodJSON("native")
|
||||
}
|
||||
|
||||
// WAV / AIFF: write tags into an embedded ID3v2.4 chunk natively.
|
||||
if isWavFile {
|
||||
if err := WriteWAVTags(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write WAV metadata: %w", err)
|
||||
}
|
||||
return successMethodJSON("native_wav")
|
||||
}
|
||||
if isAiffFile {
|
||||
if err := WriteAIFFTags(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write AIFF metadata: %w", err)
|
||||
}
|
||||
return successMethodJSON("native_aiff")
|
||||
}
|
||||
|
||||
if isApeFile {
|
||||
meta := audioMetadataFromEditFields(fields)
|
||||
|
||||
newItems := AudioMetadataToAPEItems(meta)
|
||||
|
||||
// If a cover image was provided, embed it as a binary APE item.
|
||||
// APEv2 cover format: "cover.jpg\0<binary image data>", flagged binary.
|
||||
if coverPath != "" {
|
||||
coverData, coverErr := os.ReadFile(coverPath)
|
||||
if coverErr == nil && len(coverData) > 0 {
|
||||
// The value is "filename\0" + raw bytes. We store the
|
||||
// description as the Value field, but since the item is
|
||||
// flagged binary, the writer serializes it verbatim.
|
||||
desc := "cover.jpg\x00"
|
||||
binaryValue := desc + string(coverData)
|
||||
newItems = append(newItems, APETagItem{
|
||||
Key: "Cover Art (Front)",
|
||||
Value: binaryValue,
|
||||
Flags: apeItemFlagBinary,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Build the set of APE keys that the edit explicitly controls.
|
||||
// Even if the value is empty (user cleared the field), the old
|
||||
// value must be removed during merge.
|
||||
overrideKeys := apeKeysFromFields(fields)
|
||||
if coverPath != "" {
|
||||
overrideKeys["COVER ART (FRONT)"] = struct{}{}
|
||||
}
|
||||
|
||||
// Read existing tags so we can merge rather than replace.
|
||||
// This preserves cover art and custom items not in the edit set.
|
||||
existingTag, _ := ReadAPETags(filePath)
|
||||
var finalItems []APETagItem
|
||||
if existingTag != nil && len(existingTag.Items) > 0 {
|
||||
finalItems = MergeAPEItems(existingTag.Items, newItems, overrideKeys)
|
||||
} else {
|
||||
finalItems = newItems
|
||||
}
|
||||
|
||||
tag := &APETag{
|
||||
Version: apeTagVersion2,
|
||||
Items: finalItems,
|
||||
}
|
||||
|
||||
if err := WriteAPETags(filePath, tag); err != nil {
|
||||
return "", fmt.Errorf("failed to write APE tags: %w", err)
|
||||
}
|
||||
|
||||
return successMethodJSON("native_ape")
|
||||
}
|
||||
|
||||
// MP3, Ogg/Opus, and M4A have native editors that preserve foreign
|
||||
// tags and skip the ffmpeg remux. Any failure falls back to the ffmpeg
|
||||
// response so callers keep the old behavior for exotic files.
|
||||
isMp3 := strings.HasSuffix(lower, ".mp3")
|
||||
isOggFile := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
|
||||
|
||||
if isMp3 {
|
||||
if err := EditMP3Fields(filePath, fields); err != nil {
|
||||
GoLog("[Metadata] Native MP3 edit failed, falling back to ffmpeg: %v\n", err)
|
||||
} else {
|
||||
return successMethodJSON("native_mp3")
|
||||
}
|
||||
}
|
||||
if isOggFile {
|
||||
if err := EditOggFields(filePath, fields); err != nil {
|
||||
GoLog("[Metadata] Native Ogg edit failed, falling back to ffmpeg: %v\n", err)
|
||||
} else {
|
||||
return successMethodJSON("native_ogg")
|
||||
}
|
||||
}
|
||||
if isM4AFile || isMP4ContainerFile(filePath) {
|
||||
if err := EditM4AFields(filePath, fields); err != nil {
|
||||
GoLog("[Metadata] Native M4A edit failed, falling back to ffmpeg: %v\n", err)
|
||||
} else {
|
||||
return successMethodJSON("native_m4a")
|
||||
}
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"method": "ffmpeg",
|
||||
"fields": fields,
|
||||
}
|
||||
s, _ := marshalJSONString(resp)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func isMP4ContainerFile(filePath string) bool {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
header := make([]byte, 12)
|
||||
n, err := f.Read(header)
|
||||
if err != nil || n < 8 {
|
||||
return false
|
||||
}
|
||||
return string(header[4:8]) == "ftyp"
|
||||
}
|
||||
|
||||
func hasOnlyM4AReplayGainFields(fields map[string]string) bool {
|
||||
allowed := map[string]struct{}{
|
||||
"replaygain_track_gain": {},
|
||||
"replaygain_track_peak": {},
|
||||
"replaygain_album_gain": {},
|
||||
"replaygain_album_peak": {},
|
||||
}
|
||||
|
||||
hasReplayGain := false
|
||||
for key, value := range fields {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[strings.ToLower(strings.TrimSpace(key))]; ok {
|
||||
hasReplayGain = true
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return hasReplayGain
|
||||
}
|
||||
|
||||
// RewriteSplitArtistTagsExport rewrites ARTIST and ALBUMARTIST Vorbis
|
||||
// comments in a FLAC file as multiple separate entries (one per artist).
|
||||
// Call this after FFmpeg metadata embedding to fix split artist tags,
|
||||
// since FFmpeg deduplicates -metadata keys and only keeps the last value.
|
||||
func RewriteSplitArtistTagsExport(filePath, artist, albumArtist string) (string, error) {
|
||||
err := RewriteSplitArtistTags(filePath, artist, albumArtist)
|
||||
if err != nil {
|
||||
return errorResponse("Failed to rewrite artist tags: " + err.Error())
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"message": "Split artist tags written successfully",
|
||||
}
|
||||
|
||||
s, _ := marshalJSONString(resp)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("no cover URL provided")
|
||||
}
|
||||
|
||||
data, err := downloadCoverToMemory(coverURL, maxQuality)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download cover: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outputPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write cover file: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[Cover] Downloaded cover to: %s (%d KB)\n", outputPath, len(data)/1024)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ExtractCoverToFile(audioPath string, outputPath string) error {
|
||||
lower := strings.ToLower(audioPath)
|
||||
|
||||
var coverData []byte
|
||||
var err error
|
||||
|
||||
if strings.HasSuffix(lower, ".flac") {
|
||||
coverData, err = ExtractCoverArt(audioPath)
|
||||
} else if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
|
||||
coverData, err = extractCoverFromM4A(audioPath)
|
||||
} else if strings.HasSuffix(lower, ".mp3") {
|
||||
coverData, _, err = extractMP3CoverArt(audioPath)
|
||||
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
|
||||
coverData, _, err = extractOggCoverArt(audioPath)
|
||||
} else {
|
||||
return fmt.Errorf("unsupported audio format for cover extraction")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract cover: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outputPath, coverData, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write cover file: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[Cover] Extracted cover art to: %s (%d KB)\n", outputPath, len(coverData)/1024)
|
||||
return nil
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
const musicBrainzAPIBase = "https://musicbrainz.org/ws/2"
|
||||
|
||||
type musicBrainzTag struct {
|
||||
Count int `json:"count"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type musicBrainzRecordingResponse struct {
|
||||
Recordings []struct {
|
||||
Tags []musicBrainzTag `json:"tags"`
|
||||
} `json:"recordings"`
|
||||
}
|
||||
|
||||
type musicBrainzArtistCredit struct {
|
||||
Name string `json:"name"`
|
||||
JoinPhrase string `json:"joinphrase"`
|
||||
}
|
||||
|
||||
type musicBrainzRelease struct {
|
||||
Title string `json:"title"`
|
||||
ArtistCredit []musicBrainzArtistCredit `json:"artist-credit"`
|
||||
}
|
||||
|
||||
type musicBrainzAlbumArtistResponse struct {
|
||||
Recordings []struct {
|
||||
Releases []musicBrainzRelease `json:"releases"`
|
||||
} `json:"recordings"`
|
||||
}
|
||||
|
||||
func formatMusicBrainzGenre(tags []musicBrainzTag) string {
|
||||
if len(tags) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
caser := cases.Title(language.English)
|
||||
seen := make(map[string]struct{}, len(tags))
|
||||
maxCount := -1
|
||||
bestTag := ""
|
||||
|
||||
for _, tag := range tags {
|
||||
name := strings.TrimSpace(tag.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.ToLower(name)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
|
||||
formatted := caser.String(name)
|
||||
if tag.Count > maxCount {
|
||||
maxCount = tag.Count
|
||||
bestTag = formatted
|
||||
}
|
||||
}
|
||||
|
||||
return bestTag
|
||||
}
|
||||
|
||||
func formatMusicBrainzArtistCredit(credits []musicBrainzArtistCredit) string {
|
||||
var builder strings.Builder
|
||||
for _, credit := range credits {
|
||||
name := strings.TrimSpace(credit.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
builder.WriteString(name)
|
||||
builder.WriteString(credit.JoinPhrase)
|
||||
}
|
||||
return strings.TrimSpace(builder.String())
|
||||
}
|
||||
|
||||
func selectMusicBrainzAlbumArtist(releases []musicBrainzRelease, albumName string) string {
|
||||
if len(releases) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
normalizedAlbum := strings.ToLower(strings.TrimSpace(albumName))
|
||||
if normalizedAlbum != "" {
|
||||
for _, release := range releases {
|
||||
if strings.ToLower(strings.TrimSpace(release.Title)) != normalizedAlbum {
|
||||
continue
|
||||
}
|
||||
if albumArtist := formatMusicBrainzArtistCredit(release.ArtistCredit); albumArtist != "" {
|
||||
return albumArtist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, release := range releases {
|
||||
if albumArtist := formatMusicBrainzArtistCredit(release.ArtistCredit); albumArtist != "" {
|
||||
return albumArtist
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// fetchMusicBrainzRecordingByISRC queries the MusicBrainz recording endpoint
|
||||
// for the given ISRC with the supplied inc= parameter, retrying up to 3 times,
|
||||
// and decodes the JSON response into payload. It returns the normalized ISRC.
|
||||
func fetchMusicBrainzRecordingByISRC(isrc string, inc string, payload any) (string, error) {
|
||||
normalizedISRC := strings.ToUpper(strings.TrimSpace(isrc))
|
||||
if normalizedISRC == "" {
|
||||
return "", fmt.Errorf("no ISRC provided")
|
||||
}
|
||||
|
||||
client := NewMetadataHTTPClient(10 * time.Second)
|
||||
query := fmt.Sprintf("isrc:%s", normalizedISRC)
|
||||
reqURL := fmt.Sprintf(
|
||||
"%s/recording?query=%s&fmt=json&inc=%s",
|
||||
musicBrainzAPIBase,
|
||||
url.QueryEscape(query),
|
||||
inc,
|
||||
)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
var resp *http.Response
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
resp, lastErr = client.Do(req)
|
||||
if lastErr == nil && resp.StatusCode == http.StatusOK {
|
||||
break
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if attempt < 2 {
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
if resp == nil {
|
||||
return "", fmt.Errorf("MusicBrainz request failed without response")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return "", fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return normalizedISRC, nil
|
||||
}
|
||||
|
||||
func FetchMusicBrainzAlbumArtistByISRC(isrc string, albumName string) (string, error) {
|
||||
var payload musicBrainzAlbumArtistResponse
|
||||
normalizedISRC, err := fetchMusicBrainzRecordingByISRC(isrc, "releases+artist-credits", &payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, recording := range payload.Recordings {
|
||||
if albumArtist := selectMusicBrainzAlbumArtist(recording.Releases, albumName); albumArtist != "" {
|
||||
return albumArtist, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no MusicBrainz album artist found for ISRC: %s", normalizedISRC)
|
||||
}
|
||||
|
||||
func FetchMusicBrainzGenreByISRC(isrc string) (string, error) {
|
||||
var payload musicBrainzRecordingResponse
|
||||
normalizedISRC, err := fetchMusicBrainzRecordingByISRC(isrc, "tags", &payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(payload.Recordings) == 0 {
|
||||
return "", fmt.Errorf("no recordings found for ISRC: %s", normalizedISRC)
|
||||
}
|
||||
|
||||
genre := formatMusicBrainzGenre(payload.Recordings[0].Tags)
|
||||
if genre == "" {
|
||||
return "", fmt.Errorf("no MusicBrainz genre tags found for ISRC: %s", normalizedISRC)
|
||||
}
|
||||
return genre, nil
|
||||
}
|
||||
@@ -1,785 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||
return GetDeezerClient().GetExtendedMetadataByISRC(ctx, isrc)
|
||||
}
|
||||
|
||||
var fetchMusicBrainzGenreByISRC = FetchMusicBrainzGenreByISRC
|
||||
|
||||
var fetchMusicBrainzAlbumArtistByISRC = FetchMusicBrainzAlbumArtistByISRC
|
||||
|
||||
type reEnrichRequest struct {
|
||||
FilePath string `json:"file_path"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
MaxQuality bool `json:"max_quality"`
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
TrackName string `json:"track_name"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumName string `json:"album_name"`
|
||||
AlbumArtist string `json:"album_artist"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
ISRC string `json:"isrc"`
|
||||
Genre string `json:"genre"`
|
||||
Label string `json:"label"`
|
||||
Copyright string `json:"copyright"`
|
||||
Composer string `json:"composer"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
SearchOnline bool `json:"search_online"`
|
||||
UpdateFields []string `json:"update_fields,omitempty"`
|
||||
}
|
||||
|
||||
// shouldUpdateField returns true if the given field group should be updated.
|
||||
// When UpdateFields is empty/nil, all fields are updated (backward compatible).
|
||||
func (r *reEnrichRequest) shouldUpdateField(field string) bool {
|
||||
if len(r.UpdateFields) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, f := range r.UpdateFields {
|
||||
if f == field {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// lyricsEmbedEnabled reports whether lyrics should be written into the audio
|
||||
// file's tags. It mirrors the download path semantics: 'embed' and 'both' embed,
|
||||
// 'external' does not. An empty mode keeps the legacy behavior (embed) so older
|
||||
// callers that do not send lyrics_mode are unaffected.
|
||||
func (r *reEnrichRequest) lyricsEmbedEnabled() bool {
|
||||
return strings.ToLower(strings.TrimSpace(r.LyricsMode)) != "external"
|
||||
}
|
||||
|
||||
// lyricsSidecarEnabled reports whether a .lrc sidecar file should be written
|
||||
// next to the audio file. Only 'external' and 'both' request a sidecar.
|
||||
func (r *reEnrichRequest) lyricsSidecarEnabled() bool {
|
||||
mode := strings.ToLower(strings.TrimSpace(r.LyricsMode))
|
||||
return mode == "external" || mode == "both"
|
||||
}
|
||||
|
||||
// reEnrichSameRelease reports whether the candidate track appears to come
|
||||
// from the same release as the file's existing album. An ISRC identifies a
|
||||
// recording, not a release: the same song often also resolves to a
|
||||
// compilation, whose album name, cover, and track positions must not
|
||||
// replace the original release's.
|
||||
func reEnrichSameRelease(currentAlbum, candidateAlbum string) bool {
|
||||
if isPlaceholderReEnrichValue(currentAlbum) ||
|
||||
strings.TrimSpace(candidateAlbum) == "" {
|
||||
return true
|
||||
}
|
||||
return titlesMatch(currentAlbum, candidateAlbum)
|
||||
}
|
||||
|
||||
func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
|
||||
sameRelease := reEnrichSameRelease(req.AlbumName, track.AlbumName)
|
||||
if !sameRelease {
|
||||
GoLog("[ReEnrich] Candidate album %q differs from file album %q; keeping release identity (album, cover, positions, date)\n",
|
||||
track.AlbumName, req.AlbumName)
|
||||
}
|
||||
|
||||
if track.SpotifyID != "" {
|
||||
req.SpotifyID = track.SpotifyID
|
||||
} else if track.DeezerID != "" {
|
||||
req.SpotifyID = "deezer:" + track.DeezerID
|
||||
} else if track.QobuzID != "" {
|
||||
req.SpotifyID = "qobuz:" + track.QobuzID
|
||||
} else if track.TidalID != "" {
|
||||
req.SpotifyID = "tidal:" + track.TidalID
|
||||
} else if track.ID != "" {
|
||||
req.SpotifyID = track.ID
|
||||
}
|
||||
|
||||
if req.shouldUpdateField("basic_tags") {
|
||||
if track.Name != "" {
|
||||
req.TrackName = track.Name
|
||||
}
|
||||
if track.Artists != "" {
|
||||
req.ArtistName = track.Artists
|
||||
}
|
||||
if sameRelease {
|
||||
if track.AlbumName != "" {
|
||||
req.AlbumName = track.AlbumName
|
||||
}
|
||||
if track.AlbumArtist != "" {
|
||||
req.AlbumArtist = track.AlbumArtist
|
||||
}
|
||||
}
|
||||
}
|
||||
if sameRelease && req.shouldUpdateField("track_info") {
|
||||
if track.TrackNumber > 0 {
|
||||
req.TrackNumber = track.TrackNumber
|
||||
}
|
||||
if track.TotalTracks > 0 {
|
||||
req.TotalTracks = track.TotalTracks
|
||||
}
|
||||
if track.DiscNumber > 0 {
|
||||
req.DiscNumber = track.DiscNumber
|
||||
}
|
||||
if track.TotalDiscs > 0 {
|
||||
req.TotalDiscs = track.TotalDiscs
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("release_info") {
|
||||
if sameRelease && track.ReleaseDate != "" {
|
||||
req.ReleaseDate = track.ReleaseDate
|
||||
}
|
||||
if track.ISRC != "" {
|
||||
req.ISRC = track.ISRC
|
||||
}
|
||||
}
|
||||
if sameRelease && req.shouldUpdateField("cover") {
|
||||
if coverURL := track.ResolvedCoverURL(); coverURL != "" {
|
||||
req.CoverURL = coverURL
|
||||
}
|
||||
}
|
||||
if track.DurationMS > 0 {
|
||||
req.DurationMs = int64(track.DurationMS)
|
||||
}
|
||||
if req.shouldUpdateField("extra") {
|
||||
if track.Genre != "" {
|
||||
req.Genre = track.Genre
|
||||
}
|
||||
if track.Label != "" {
|
||||
req.Label = track.Label
|
||||
}
|
||||
if track.Copyright != "" {
|
||||
req.Copyright = track.Copyright
|
||||
}
|
||||
if track.Composer != "" {
|
||||
req.Composer = track.Composer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isPlaceholderReEnrichValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "unknown", "unknown artist", "unknown title", "unknown album":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildReEnrichSearchQuery(req reEnrichRequest) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if !isPlaceholderReEnrichValue(req.TrackName) {
|
||||
parts = append(parts, strings.TrimSpace(req.TrackName))
|
||||
}
|
||||
if !isPlaceholderReEnrichValue(req.ArtistName) {
|
||||
parts = append(parts, strings.TrimSpace(req.ArtistName))
|
||||
}
|
||||
if len(parts) == 0 && !isPlaceholderReEnrichValue(req.AlbumName) {
|
||||
parts = append(parts, strings.TrimSpace(req.AlbumName))
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
|
||||
return DownloadRequest{
|
||||
TrackName: req.TrackName,
|
||||
ArtistName: req.ArtistName,
|
||||
AlbumName: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
ISRC: req.ISRC,
|
||||
DurationMS: int(req.DurationMs),
|
||||
ArtistTagMode: req.ArtistTagMode,
|
||||
TrackNumber: req.TrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
TotalDiscs: req.TotalDiscs,
|
||||
Composer: req.Composer,
|
||||
}
|
||||
}
|
||||
|
||||
func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[string]string {
|
||||
metadata := map[string]string{}
|
||||
if req.shouldUpdateField("basic_tags") {
|
||||
if req.TrackName != "" {
|
||||
metadata["TITLE"] = req.TrackName
|
||||
}
|
||||
if req.ArtistName != "" {
|
||||
metadata["ARTIST"] = req.ArtistName
|
||||
}
|
||||
if req.AlbumName != "" {
|
||||
metadata["ALBUM"] = req.AlbumName
|
||||
}
|
||||
if req.AlbumArtist != "" {
|
||||
metadata["ALBUMARTIST"] = req.AlbumArtist
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("release_info") {
|
||||
if req.ReleaseDate != "" {
|
||||
metadata["DATE"] = req.ReleaseDate
|
||||
}
|
||||
if req.ISRC != "" {
|
||||
metadata["ISRC"] = req.ISRC
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("extra") {
|
||||
if req.Genre != "" {
|
||||
metadata["GENRE"] = req.Genre
|
||||
}
|
||||
if req.Label != "" {
|
||||
metadata["ORGANIZATION"] = req.Label
|
||||
}
|
||||
if req.Copyright != "" {
|
||||
metadata["COPYRIGHT"] = req.Copyright
|
||||
}
|
||||
if req.Composer != "" {
|
||||
metadata["COMPOSER"] = req.Composer
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("track_info") {
|
||||
if req.TrackNumber > 0 {
|
||||
metadata["TRACKNUMBER"] = formatIndexValue(req.TrackNumber, req.TotalTracks)
|
||||
}
|
||||
if req.DiscNumber > 0 {
|
||||
metadata["DISCNUMBER"] = formatIndexValue(req.DiscNumber, req.TotalDiscs)
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("lyrics") {
|
||||
if lyricsLRC != "" && req.lyricsEmbedEnabled() {
|
||||
metadata["LYRICS"] = lyricsLRC
|
||||
metadata["UNSYNCEDLYRICS"] = lyricsLRC
|
||||
}
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *ExtTrackMetadata {
|
||||
if len(tracks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
downloadReq := reEnrichDownloadRequest(req)
|
||||
currentISRC := strings.TrimSpace(req.ISRC)
|
||||
currentAlbum := strings.TrimSpace(req.AlbumName)
|
||||
effectiveTrackName := req.TrackName
|
||||
if isPlaceholderReEnrichValue(effectiveTrackName) {
|
||||
effectiveTrackName = ""
|
||||
}
|
||||
effectiveArtistName := req.ArtistName
|
||||
if isPlaceholderReEnrichValue(effectiveArtistName) {
|
||||
effectiveArtistName = ""
|
||||
}
|
||||
var best *ExtTrackMetadata
|
||||
bestScore := -1 << 30
|
||||
|
||||
for i := range tracks {
|
||||
track := &tracks[i]
|
||||
score := 0
|
||||
exactISRCMatch := currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC))
|
||||
titleMatches := effectiveTrackName != "" && track.Name != "" && titlesMatch(effectiveTrackName, track.Name)
|
||||
artistMatches := effectiveArtistName != "" && track.Artists != "" && artistsMatch(effectiveArtistName, track.Artists)
|
||||
albumMatches := currentAlbum != "" && track.AlbumName != "" && titlesMatch(currentAlbum, track.AlbumName)
|
||||
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: track.Name,
|
||||
ArtistName: track.Artists,
|
||||
ISRC: track.ISRC,
|
||||
Duration: track.DurationMS / 1000,
|
||||
}
|
||||
verified := trackMatchesRequest(downloadReq, resolved, "ReEnrich")
|
||||
|
||||
if !exactISRCMatch {
|
||||
if effectiveTrackName != "" && !titleMatches {
|
||||
continue
|
||||
}
|
||||
if effectiveArtistName != "" && !artistMatches {
|
||||
continue
|
||||
}
|
||||
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum != "" && !albumMatches {
|
||||
continue
|
||||
}
|
||||
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum == "" && !verified {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if verified {
|
||||
score += 2000
|
||||
}
|
||||
|
||||
if exactISRCMatch {
|
||||
score += 10000
|
||||
}
|
||||
if titleMatches {
|
||||
score += 400
|
||||
}
|
||||
if artistMatches {
|
||||
score += 320
|
||||
}
|
||||
if currentAlbum != "" && track.AlbumName != "" {
|
||||
switch {
|
||||
case albumMatches:
|
||||
score += 120
|
||||
case strings.Contains(strings.ToLower(track.AlbumName), strings.ToLower(currentAlbum)),
|
||||
strings.Contains(strings.ToLower(currentAlbum), strings.ToLower(track.AlbumName)):
|
||||
score += 50
|
||||
}
|
||||
}
|
||||
|
||||
if req.DurationMs > 0 && track.DurationMS > 0 {
|
||||
diff := int(req.DurationMs/1000) - (track.DurationMS / 1000)
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff <= 10 {
|
||||
score += 80
|
||||
}
|
||||
}
|
||||
|
||||
if track.ReleaseDate != "" {
|
||||
score += 70
|
||||
}
|
||||
if track.TrackNumber > 0 {
|
||||
score += 20
|
||||
}
|
||||
if track.DiscNumber > 0 {
|
||||
score += 10
|
||||
}
|
||||
if track.ISRC != "" {
|
||||
score += 40
|
||||
}
|
||||
|
||||
if best == nil || score > bestScore {
|
||||
best = track
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
func extTrackFromTrackMetadata(track *TrackMetadata, providerID string) *ExtTrackMetadata {
|
||||
if track == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:"))
|
||||
return &ExtTrackMetadata{
|
||||
ID: track.SpotifyID,
|
||||
Name: track.Name,
|
||||
Artists: track.Artists,
|
||||
AlbumName: track.AlbumName,
|
||||
AlbumArtist: track.AlbumArtist,
|
||||
DurationMS: track.DurationMS,
|
||||
CoverURL: track.Images,
|
||||
Images: track.Images,
|
||||
ReleaseDate: track.ReleaseDate,
|
||||
TrackNumber: track.TrackNumber,
|
||||
TotalTracks: track.TotalTracks,
|
||||
DiscNumber: track.DiscNumber,
|
||||
TotalDiscs: track.TotalDiscs,
|
||||
ISRC: track.ISRC,
|
||||
ProviderID: providerID,
|
||||
DeezerID: deezerID,
|
||||
SpotifyID: track.SpotifyID,
|
||||
Composer: track.Composer,
|
||||
Explicit: track.Explicit,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeReEnrichSpotifyTrackID(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if extracted := extractSpotifyIDFromURL(trimmed); extracted != "" {
|
||||
return extracted
|
||||
}
|
||||
if len(trimmed) == 22 && !strings.Contains(trimmed, ":") && !strings.Contains(trimmed, "/") {
|
||||
return trimmed
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveReEnrichTrackFromIdentifiers(req reEnrichRequest) (*ExtTrackMetadata, error) {
|
||||
deezerClient := GetDeezerClient()
|
||||
downloadReq := reEnrichDownloadRequest(req)
|
||||
|
||||
if isrc := strings.TrimSpace(req.ISRC); isrc != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
track, err := deezerClient.SearchByISRC(ctx, isrc)
|
||||
cancel()
|
||||
if err == nil && track != nil {
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: track.Name,
|
||||
ArtistName: track.Artists,
|
||||
ISRC: track.ISRC,
|
||||
Duration: track.DurationMS / 1000,
|
||||
}
|
||||
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
|
||||
return extTrackFromTrackMetadata(track, "deezer"), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceTrackID := strings.TrimSpace(req.SpotifyID)
|
||||
if sourceTrackID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
deezerID := strings.TrimSpace(strings.TrimPrefix(sourceTrackID, "deezer:"))
|
||||
if deezerID == sourceTrackID {
|
||||
deezerID = extractDeezerIDFromURL(sourceTrackID)
|
||||
}
|
||||
if deezerID == "" {
|
||||
spotifyID := normalizeReEnrichSpotifyTrackID(sourceTrackID)
|
||||
if spotifyID != "" {
|
||||
resolvedDeezerID, err := NewSongLinkClient().GetDeezerIDFromSpotify(spotifyID)
|
||||
if err == nil {
|
||||
deezerID = strings.TrimSpace(resolvedDeezerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if deezerID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
||||
if err != nil || trackResp == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
track := &trackResp.Track
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: track.Name,
|
||||
ArtistName: track.Artists,
|
||||
ISRC: track.ISRC,
|
||||
Duration: track.DurationMS / 1000,
|
||||
}
|
||||
if !trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return extTrackFromTrackMetadata(track, "deezer"), nil
|
||||
}
|
||||
|
||||
func preferredReleaseMetadata(
|
||||
req DownloadRequest,
|
||||
album string,
|
||||
releaseDate string,
|
||||
trackNumber int,
|
||||
discNumber int,
|
||||
) (string, string, int, int) {
|
||||
preferredAlbum := strings.TrimSpace(req.AlbumName)
|
||||
if preferredAlbum == "" {
|
||||
preferredAlbum = album
|
||||
}
|
||||
|
||||
preferredReleaseDate := strings.TrimSpace(req.ReleaseDate)
|
||||
if preferredReleaseDate == "" {
|
||||
preferredReleaseDate = releaseDate
|
||||
}
|
||||
|
||||
preferredTrackNumber := req.TrackNumber
|
||||
if preferredTrackNumber == 0 {
|
||||
preferredTrackNumber = trackNumber
|
||||
}
|
||||
|
||||
preferredDiscNumber := req.DiscNumber
|
||||
if preferredDiscNumber == 0 {
|
||||
preferredDiscNumber = discNumber
|
||||
}
|
||||
|
||||
return preferredAlbum, preferredReleaseDate, preferredTrackNumber, preferredDiscNumber
|
||||
}
|
||||
|
||||
// ReEnrichFile re-embeds metadata, cover art, and lyrics into an existing audio file.
|
||||
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
|
||||
// complete metadata from the internet before embedding.
|
||||
func ReEnrichFile(requestJSON string) (string, error) {
|
||||
var req reEnrichRequest
|
||||
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return "", fmt.Errorf("failed to parse request: %w", err)
|
||||
}
|
||||
|
||||
if req.FilePath == "" {
|
||||
return "", fmt.Errorf("file_path is required")
|
||||
}
|
||||
|
||||
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
|
||||
|
||||
if req.SearchOnline {
|
||||
found := false
|
||||
|
||||
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
|
||||
manager := getExtensionManager()
|
||||
if identifierTrack, err := resolveReEnrichTrackFromIdentifiers(req); err == nil && identifierTrack != nil {
|
||||
GoLog("[ReEnrich] Identifier-first metadata match (%s): %s - %s (album: %s, date: %s)\n",
|
||||
identifierTrack.ProviderID, identifierTrack.Name, identifierTrack.Artists, identifierTrack.AlbumName, identifierTrack.ReleaseDate)
|
||||
applyReEnrichTrackMetadata(&req, *identifierTrack)
|
||||
found = true
|
||||
}
|
||||
|
||||
searchQuery := buildReEnrichSearchQuery(req)
|
||||
if searchQuery != "" {
|
||||
GoLog("[ReEnrich] Searching online metadata for query: %s\n", searchQuery)
|
||||
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
||||
if searchErr == nil && len(tracks) > 0 {
|
||||
track := selectBestReEnrichTrack(req, tracks)
|
||||
if track != nil {
|
||||
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
|
||||
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
|
||||
applyReEnrichTrackMetadata(&req, *track)
|
||||
found = true
|
||||
}
|
||||
} else if searchErr != nil {
|
||||
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
|
||||
}
|
||||
} else {
|
||||
GoLog("[ReEnrich] Skipping provider search: no usable title/artist/album query\n")
|
||||
}
|
||||
|
||||
if req.shouldUpdateField("basic_tags") && req.AlbumArtist == "" && req.ISRC != "" {
|
||||
albumArtist, err := fetchMusicBrainzAlbumArtistByISRC(req.ISRC, req.AlbumName)
|
||||
if err != nil {
|
||||
GoLog("[ReEnrich] Failed to get album artist from MusicBrainz: %v\n", err)
|
||||
} else if strings.TrimSpace(albumArtist) != "" {
|
||||
req.AlbumArtist = strings.TrimSpace(albumArtist)
|
||||
GoLog("[ReEnrich] Album artist fallback from MusicBrainz: %s\n", req.AlbumArtist)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
// Try to enrich extra metadata from ISRC if not already set.
|
||||
if found && req.ISRC != "" && req.shouldUpdateField("extra") && (req.Genre == "" || req.Label == "" || req.Copyright == "") {
|
||||
enrichExtraMetadataByISRC("ReEnrich", req.ISRC, &req.Genre, &req.Label, &req.Copyright)
|
||||
}
|
||||
|
||||
if !found {
|
||||
GoLog("[ReEnrich] No online match found, using existing metadata\n")
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n",
|
||||
req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist)
|
||||
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
|
||||
req.TrackNumber, req.DiscNumber, req.ReleaseDate, req.ISRC, req.Genre, req.Label)
|
||||
|
||||
lower := strings.ToLower(req.FilePath)
|
||||
isFlac := strings.HasSuffix(lower, ".flac")
|
||||
|
||||
var coverTempPath string
|
||||
var coverDataBytes []byte
|
||||
if req.CoverURL != "" && req.shouldUpdateField("cover") {
|
||||
coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality)
|
||||
if err != nil {
|
||||
GoLog("[ReEnrich] Failed to download cover: %v\n", err)
|
||||
} else {
|
||||
coverDataBytes = coverData
|
||||
GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024)
|
||||
// MP3/Opus requires a real image file path for Dart FFmpeg.
|
||||
// FLAC uses in-memory embed and does not require temp files.
|
||||
if !isFlac {
|
||||
tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg")
|
||||
if err != nil {
|
||||
fallbackDir := filepath.Dir(req.FilePath)
|
||||
if fallbackDir == "" || fallbackDir == "." {
|
||||
GoLog("[ReEnrich] Failed to create cover temp file: %v\n", err)
|
||||
} else {
|
||||
tmpFile, err = os.CreateTemp(fallbackDir, "reenrich_cover_*.jpg")
|
||||
if err != nil {
|
||||
GoLog("[ReEnrich] Failed to create cover temp file (fallback dir %s): %v\n", fallbackDir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil && tmpFile != nil {
|
||||
coverTempPath = tmpFile.Name()
|
||||
if _, writeErr := tmpFile.Write(coverData); writeErr != nil {
|
||||
GoLog("[ReEnrich] Failed writing cover temp file: %v\n", writeErr)
|
||||
tmpFile.Close()
|
||||
os.Remove(coverTempPath)
|
||||
coverTempPath = ""
|
||||
} else if closeErr := tmpFile.Close(); closeErr != nil {
|
||||
GoLog("[ReEnrich] Failed closing cover temp file: %v\n", closeErr)
|
||||
os.Remove(coverTempPath)
|
||||
coverTempPath = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only cleanup cover temp for FLAC (native embed).
|
||||
// For MP3/Opus, Dart needs the file for FFmpeg — Dart handles cleanup.
|
||||
cleanupCover := true
|
||||
|
||||
defer func() {
|
||||
if cleanupCover && coverTempPath != "" {
|
||||
os.Remove(coverTempPath)
|
||||
}
|
||||
}()
|
||||
|
||||
// Preserve existing lyrics when online enrichment does not return a replacement.
|
||||
var lyricsLRC string
|
||||
if req.shouldUpdateField("lyrics") {
|
||||
existingLyrics, existingLyricsErr := ExtractLyrics(req.FilePath)
|
||||
if existingLyricsErr == nil && strings.TrimSpace(existingLyrics) != "" {
|
||||
lyricsLRC = existingLyrics
|
||||
GoLog("[ReEnrich] Preserving existing embedded/sidecar lyrics\n")
|
||||
}
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && req.shouldUpdateField("lyrics") {
|
||||
client := NewLyricsClient()
|
||||
durationSec := float64(req.DurationMs) / 1000.0
|
||||
lyrics, err := client.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, durationSec)
|
||||
if err != nil {
|
||||
GoLog("[ReEnrich] Lyrics not found: %v\n", err)
|
||||
} else if !lyrics.Instrumental {
|
||||
lyricsLRC = convertToLRCWithMetadata(lyrics, req.TrackName, req.ArtistName)
|
||||
GoLog("[ReEnrich] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
||||
} else {
|
||||
GoLog("[ReEnrich] Track is instrumental\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Build enrichedMeta map: only include fields from selected update groups
|
||||
// so that the caller (Dart) does not overwrite non-selected metadata in its
|
||||
// local library database with potentially stale cached values.
|
||||
enrichedMeta := map[string]any{
|
||||
"spotify_id": req.SpotifyID,
|
||||
"duration_ms": req.DurationMs,
|
||||
}
|
||||
if req.shouldUpdateField("basic_tags") {
|
||||
enrichedMeta["track_name"] = req.TrackName
|
||||
enrichedMeta["artist_name"] = req.ArtistName
|
||||
enrichedMeta["album_name"] = req.AlbumName
|
||||
enrichedMeta["album_artist"] = req.AlbumArtist
|
||||
}
|
||||
if req.shouldUpdateField("track_info") {
|
||||
enrichedMeta["track_number"] = req.TrackNumber
|
||||
enrichedMeta["total_tracks"] = req.TotalTracks
|
||||
enrichedMeta["disc_number"] = req.DiscNumber
|
||||
enrichedMeta["total_discs"] = req.TotalDiscs
|
||||
}
|
||||
if req.shouldUpdateField("release_info") {
|
||||
enrichedMeta["release_date"] = req.ReleaseDate
|
||||
enrichedMeta["isrc"] = req.ISRC
|
||||
}
|
||||
if req.shouldUpdateField("cover") {
|
||||
enrichedMeta["cover_url"] = req.CoverURL
|
||||
}
|
||||
if req.shouldUpdateField("extra") {
|
||||
enrichedMeta["genre"] = req.Genre
|
||||
enrichedMeta["label"] = req.Label
|
||||
enrichedMeta["copyright"] = req.Copyright
|
||||
enrichedMeta["composer"] = req.Composer
|
||||
}
|
||||
|
||||
if isFlac {
|
||||
// Only populate Metadata fields for selected update groups; empty/zero
|
||||
// values cause EmbedMetadata's setComment() to skip those tags,
|
||||
// preserving whatever is already in the file.
|
||||
metadata := Metadata{
|
||||
ArtistTagMode: req.ArtistTagMode,
|
||||
}
|
||||
if req.shouldUpdateField("basic_tags") {
|
||||
metadata.Title = req.TrackName
|
||||
metadata.Artist = req.ArtistName
|
||||
metadata.Album = req.AlbumName
|
||||
metadata.AlbumArtist = req.AlbumArtist
|
||||
}
|
||||
if req.shouldUpdateField("track_info") {
|
||||
metadata.TrackNumber = req.TrackNumber
|
||||
metadata.TotalTracks = req.TotalTracks
|
||||
metadata.DiscNumber = req.DiscNumber
|
||||
metadata.TotalDiscs = req.TotalDiscs
|
||||
}
|
||||
if req.shouldUpdateField("release_info") {
|
||||
metadata.Date = req.ReleaseDate
|
||||
metadata.ISRC = req.ISRC
|
||||
}
|
||||
if req.shouldUpdateField("lyrics") {
|
||||
if req.lyricsEmbedEnabled() {
|
||||
metadata.Lyrics = lyricsLRC
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("extra") {
|
||||
metadata.Genre = req.Genre
|
||||
metadata.Label = req.Label
|
||||
metadata.Copyright = req.Copyright
|
||||
metadata.Composer = req.Composer
|
||||
}
|
||||
|
||||
if len(coverDataBytes) > 0 {
|
||||
if err := EmbedMetadataWithCoverData(req.FilePath, metadata, coverDataBytes); err != nil {
|
||||
return "", fmt.Errorf("failed to embed metadata with cover: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := EmbedMetadata(req.FilePath, metadata, ""); err != nil {
|
||||
return "", fmt.Errorf("failed to embed metadata: %w", err)
|
||||
}
|
||||
}
|
||||
if len(coverDataBytes) > 0 {
|
||||
embeddedCover, err := ExtractCoverArt(req.FilePath)
|
||||
if err != nil || len(embeddedCover) == 0 {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("metadata embedded but cover verification failed: %w", err)
|
||||
}
|
||||
return "", fmt.Errorf("metadata embedded but cover verification failed: empty embedded cover")
|
||||
}
|
||||
GoLog("[ReEnrich] Cover verified after embed (%d bytes)\n", len(embeddedCover))
|
||||
}
|
||||
|
||||
GoLog("[ReEnrich] FLAC metadata embedded successfully\n")
|
||||
|
||||
result := map[string]any{
|
||||
"method": "native",
|
||||
"success": true,
|
||||
"enriched_metadata": enrichedMeta,
|
||||
"lyrics": lyricsLRC,
|
||||
"write_external_lrc": req.EmbedLyrics &&
|
||||
req.shouldUpdateField("lyrics") &&
|
||||
req.lyricsSidecarEnabled() &&
|
||||
strings.TrimSpace(lyricsLRC) != "",
|
||||
}
|
||||
s, _ := marshalJSONString(result)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
|
||||
cleanupCover = false
|
||||
ffmpegMetadata := buildReEnrichFFmpegMetadata(&req, lyricsLRC)
|
||||
|
||||
result := map[string]any{
|
||||
"method": "ffmpeg",
|
||||
"cover_path": coverTempPath,
|
||||
"lyrics": lyricsLRC,
|
||||
"enriched_metadata": enrichedMeta,
|
||||
"metadata": ffmpegMetadata,
|
||||
"write_external_lrc": req.EmbedLyrics &&
|
||||
req.shouldUpdateField("lyrics") &&
|
||||
req.lyricsSidecarEnabled() &&
|
||||
strings.TrimSpace(lyricsLRC) != "",
|
||||
}
|
||||
|
||||
s, _ := marshalJSONString(result)
|
||||
return s, nil
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func InitExtensionRepoJSON(cacheDir string) error {
|
||||
initExtensionRepo(cacheDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetRepoRegistryURLJSON(registryURL string) error {
|
||||
repo := getExtensionRepo()
|
||||
if repo == nil {
|
||||
return fmt.Errorf("extension repo not initialized")
|
||||
}
|
||||
|
||||
resolved, err := resolveRegistryURL(registryURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := requireHTTPSURL(resolved, "registry"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repo.setRegistryURL(resolved)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ClearRepoRegistryURLJSON() error {
|
||||
repo := getExtensionRepo()
|
||||
if repo == nil {
|
||||
return fmt.Errorf("extension repo not initialized")
|
||||
}
|
||||
|
||||
repo.setRegistryURL("")
|
||||
repo.clearCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetRepoRegistryURLJSON() (string, error) {
|
||||
repo := getExtensionRepo()
|
||||
if repo == nil {
|
||||
return "", fmt.Errorf("extension repo not initialized")
|
||||
}
|
||||
|
||||
return repo.getRegistryURL(), nil
|
||||
}
|
||||
|
||||
func GetRepoExtensionsJSON(forceRefresh bool) (string, error) {
|
||||
repo := getExtensionRepo()
|
||||
if repo == nil {
|
||||
return "", fmt.Errorf("extension repo not initialized")
|
||||
}
|
||||
|
||||
extensions, err := repo.getExtensionsWithStatus(forceRefresh)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return marshalJSONString(extensions)
|
||||
}
|
||||
|
||||
func SearchRepoExtensionsJSON(query, category string) (string, error) {
|
||||
repo := getExtensionRepo()
|
||||
if repo == nil {
|
||||
return "", fmt.Errorf("extension repo not initialized")
|
||||
}
|
||||
|
||||
extensions, err := repo.searchExtensions(query, category)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return marshalJSONString(extensions)
|
||||
}
|
||||
|
||||
func GetRepoCategoriesJSON() (string, error) {
|
||||
repo := getExtensionRepo()
|
||||
if repo == nil {
|
||||
return "", fmt.Errorf("extension repo not initialized")
|
||||
}
|
||||
|
||||
categories := repo.getCategories()
|
||||
return marshalJSONString(categories)
|
||||
}
|
||||
|
||||
func repoExtensionPackageSuffix(downloadURL string) string {
|
||||
rawPath := downloadURL
|
||||
if parsed, err := url.Parse(downloadURL); err == nil {
|
||||
rawPath = parsed.Path
|
||||
}
|
||||
|
||||
lowerPath := strings.ToLower(rawPath)
|
||||
if strings.HasSuffix(lowerPath, ".sflx") {
|
||||
return ".sflx"
|
||||
}
|
||||
if strings.HasSuffix(lowerPath, ".spotiflac-ext") {
|
||||
return ".spotiflac-ext"
|
||||
}
|
||||
return ".spotiflac-ext"
|
||||
}
|
||||
|
||||
func buildRepoExtensionDestPath(destDir, extensionID, downloadURL string) (string, error) {
|
||||
if strings.TrimSpace(extensionID) == "" {
|
||||
return "", fmt.Errorf("invalid extension id")
|
||||
}
|
||||
|
||||
safeExtensionID := sanitizeFilename(extensionID)
|
||||
return filepath.Join(destDir, safeExtensionID+repoExtensionPackageSuffix(downloadURL)), nil
|
||||
}
|
||||
|
||||
func DownloadRepoExtensionJSON(extensionID, destDir string) (string, error) {
|
||||
repo := getExtensionRepo()
|
||||
if repo == nil {
|
||||
return "", fmt.Errorf("extension repo not initialized")
|
||||
}
|
||||
|
||||
ext, err := repo.findExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
destPath, err := buildRepoExtensionDestPath(destDir, extensionID, ext.getDownloadURL())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = repo.downloadExtension(extensionID, destPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return destPath, nil
|
||||
}
|
||||
|
||||
func ClearRepoCacheJSON() error {
|
||||
repo := getExtensionRepo()
|
||||
if repo == nil {
|
||||
return fmt.Errorf("extension repo not initialized")
|
||||
}
|
||||
|
||||
repo.clearCache()
|
||||
return nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -54,9 +55,13 @@ func TestLyricsExportWrappersWithoutNetwork(t *testing.T) {
|
||||
func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
|
||||
origClient := globalSongLinkClient
|
||||
origRetryConfig := songLinkRetryConfig
|
||||
origSearchByISRC := songLinkSearchByISRC
|
||||
origCheckFromDeezer := songLinkCheckAvailabilityFromDeezer
|
||||
defer func() {
|
||||
globalSongLinkClient = origClient
|
||||
songLinkRetryConfig = origRetryConfig
|
||||
songLinkSearchByISRC = origSearchByISRC
|
||||
songLinkCheckAvailabilityFromDeezer = origCheckFromDeezer
|
||||
SetSongLinkNetworkOptions(false, false)
|
||||
}()
|
||||
songLinkRetryConfig = func() RetryConfig {
|
||||
@@ -64,12 +69,11 @@ func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
|
||||
}
|
||||
globalSongLinkClient = &SongLinkClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
var body string
|
||||
switch req.URL.Host {
|
||||
case "api.zarz.moe":
|
||||
if req.URL.Host == "api.zarz.moe" {
|
||||
body = `{"success":true,"songUrls":{"Spotify":"https://open.spotify.com/track/spotify-1","Deezer":"https://www.deezer.com/track/101","Tidal":"https://listen.tidal.com/track/202","YouTube":"https://youtu.be/yt1","AmazonMusic":"https://music.amazon.com/tracks/amz1","Qobuz":"https://open.qobuz.com/track/303"}}`
|
||||
case "api.song.link":
|
||||
} else if req.URL.Host == "api.song.link" {
|
||||
body = `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/spotify-1"},"deezer":{"url":"https://www.deezer.com/track/101"},"tidal":{"url":"https://listen.tidal.com/track/202"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=ytm1"},"amazonMusic":{"url":"https://music.amazon.com/tracks/amz1"},"qobuz":{"url":"https://open.qobuz.com/track/303"}}}`
|
||||
default:
|
||||
} else {
|
||||
t.Fatalf("unexpected SongLink request: %s", req.URL.String())
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
|
||||
@@ -77,6 +81,15 @@ func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
|
||||
songLinkClientOnce.Do(func() {})
|
||||
|
||||
SetSongLinkNetworkOptions(true, true)
|
||||
if availabilityJSON, err := CheckAvailability("spotify-1", ""); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) {
|
||||
t.Fatalf("CheckAvailability = %q/%v", availabilityJSON, err)
|
||||
}
|
||||
if availabilityJSON, err := CheckAvailabilityFromDeezerID("101"); err != nil || !strings.Contains(availabilityJSON, `"spotify_id":"spotify-1"`) {
|
||||
t.Fatalf("CheckAvailabilityFromDeezerID = %q/%v", availabilityJSON, err)
|
||||
}
|
||||
if availabilityJSON, err := CheckAvailabilityByPlatformID("deezer", "song", "101"); err != nil || !strings.Contains(availabilityJSON, `"tidal_url"`) {
|
||||
t.Fatalf("CheckAvailabilityByPlatformID = %q/%v", availabilityJSON, err)
|
||||
}
|
||||
if spotifyID, err := GetSpotifyIDFromDeezerTrack("101"); err != nil || spotifyID != "spotify-1" {
|
||||
t.Fatalf("GetSpotifyIDFromDeezerTrack = %q/%v", spotifyID, err)
|
||||
}
|
||||
@@ -108,6 +121,15 @@ func TestSongLinkExportWrappersWithFakeClient(t *testing.T) {
|
||||
t.Fatalf("CheckAvailabilityFromURL = %#v/%v", availability, err)
|
||||
}
|
||||
|
||||
songLinkSearchByISRC = func(ctx context.Context, isrc string) (*TrackMetadata, error) {
|
||||
return &TrackMetadata{SpotifyID: "deezer:101", ExternalURL: "https://www.deezer.com/track/101"}, nil
|
||||
}
|
||||
songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) {
|
||||
return &TrackAvailability{SpotifyID: "spotify-1", Deezer: true, DeezerID: deezerTrackID}, nil
|
||||
}
|
||||
if availabilityJSON, err := CheckAvailability("", "USRC17607839"); err != nil || !strings.Contains(availabilityJSON, `"deezer_id":"101"`) {
|
||||
t.Fatalf("CheckAvailability by ISRC = %q/%v", availabilityJSON, err)
|
||||
}
|
||||
if songLinkExtractDeezerTrackID(nil) != "" || songLinkExtractDeezerTrackID(&TrackMetadata{ExternalURL: "https://www.deezer.com/track/202"}) != "202" {
|
||||
t.Fatal("songLinkExtractDeezerTrackID mismatch")
|
||||
}
|
||||
|
||||
@@ -31,79 +31,6 @@ func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
|
||||
cases := []string{
|
||||
"HTTP 401 for /tickets",
|
||||
"HTTP status 428: precondition required",
|
||||
"Verification required",
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := classifyDownloadErrorType(tc); got != "verification_required" {
|
||||
t.Fatalf("classifyDownloadErrorType(%q) = %q, want verification_required", tc, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProviderMetadataPrefersEnabledDeezerExtension(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
|
||||
t.Fatalf("InitExtensionSystem: %v", err)
|
||||
}
|
||||
CleanupExtensions()
|
||||
defer CleanupExtensions()
|
||||
|
||||
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
|
||||
ext.ID = "deezer"
|
||||
ext.Manifest.Name = "deezer"
|
||||
manager := getExtensionManager()
|
||||
manager.mu.Lock()
|
||||
manager.extensions = map[string]*loadedExtension{ext.ID: ext}
|
||||
manager.mu.Unlock()
|
||||
|
||||
jsonText, err := GetProviderMetadataJSON("deezer", "album", "201")
|
||||
if err != nil {
|
||||
t.Fatalf("GetProviderMetadataJSON deezer album: %v", err)
|
||||
}
|
||||
if !strings.Contains(jsonText, "album-track") {
|
||||
t.Fatalf("expected enabled deezer extension metadata, got %s", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTrackExportsPreserveExplicitFlag(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
|
||||
t.Fatalf("InitExtensionSystem: %v", err)
|
||||
}
|
||||
|
||||
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
|
||||
manager := getExtensionManager()
|
||||
manager.mu.Lock()
|
||||
manager.extensions = map[string]*loadedExtension{ext.ID: ext}
|
||||
manager.mu.Unlock()
|
||||
defer CleanupExtensions()
|
||||
|
||||
assertExplicit := func(name, jsonText string, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if !strings.Contains(jsonText, `"explicit":true`) {
|
||||
t.Fatalf("%s dropped explicit flag: %s", name, jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
jsonText, err := CustomSearchWithExtensionJSON(ext.ID, "needle", `{"filter":"tracks"}`)
|
||||
assertExplicit("custom search", jsonText, err)
|
||||
|
||||
for _, resourceType := range []string{"track", "album", "playlist", "artist"} {
|
||||
jsonText, err = GetProviderMetadataJSON(ext.ID, resourceType, resourceType+"-1")
|
||||
assertExplicit("provider metadata "+resourceType, jsonText, err)
|
||||
}
|
||||
|
||||
jsonText, err = HandleURLWithExtensionJSON("https://example.test/track/1")
|
||||
assertExplicit("URL handler", jsonText, err)
|
||||
}
|
||||
|
||||
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataDir := filepath.Join(dir, "data")
|
||||
@@ -126,12 +53,18 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
manager.mu.Unlock()
|
||||
}()
|
||||
|
||||
if response, err := DownloadTrack(`{}`); err != nil || !strings.Contains(response, "retired") {
|
||||
t.Fatalf("DownloadTrack = %q/%v", response, err)
|
||||
}
|
||||
if response, err := DownloadByStrategy(`not-json`); err != nil || !strings.Contains(response, "Invalid request") {
|
||||
t.Fatalf("DownloadByStrategy invalid = %q/%v", response, err)
|
||||
}
|
||||
if response, err := DownloadByStrategy(`{"use_extensions":false}`); err != nil || !strings.Contains(response, "disabled") {
|
||||
t.Fatalf("DownloadByStrategy disabled = %q/%v", response, err)
|
||||
}
|
||||
if response, err := DownloadWithFallback(`{}`); err != nil || !strings.Contains(response, "retired") {
|
||||
t.Fatalf("DownloadWithFallback = %q/%v", response, err)
|
||||
}
|
||||
|
||||
InitItemProgress("item-1")
|
||||
FinishItemProgress("item-1")
|
||||
@@ -212,6 +145,12 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
t.Fatalf("SanitizeFilename = %q", got)
|
||||
}
|
||||
|
||||
if response, err := PreWarmTrackCacheJSON(`not-json`); err != nil || !strings.Contains(response, "Invalid JSON") {
|
||||
t.Fatalf("PreWarmTrackCacheJSON invalid = %q/%v", response, err)
|
||||
}
|
||||
if response, err := PreWarmTrackCacheJSON(`[{"isrc":"ISRC","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(response, "success") {
|
||||
t.Fatalf("PreWarmTrackCacheJSON = %q/%v", response, err)
|
||||
}
|
||||
if GetTrackCacheSize() != 0 {
|
||||
t.Fatal("expected empty track cache")
|
||||
}
|
||||
@@ -242,6 +181,9 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
if err := SetExtensionFallbackProviderIDsJSON(`["coverage-ext"]`); err != nil {
|
||||
t.Fatalf("SetExtensionFallbackProviderIDsJSON: %v", err)
|
||||
}
|
||||
if jsonText, err := GetExtensionFallbackProviderIDsJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
|
||||
t.Fatalf("GetExtensionFallbackProviderIDsJSON = %q/%v", jsonText, err)
|
||||
}
|
||||
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
|
||||
t.Fatalf("reset extension fallback IDs: %v", err)
|
||||
}
|
||||
@@ -419,14 +361,14 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
CancelExtensionRequestJSON("req-home")
|
||||
|
||||
storeDir := filepath.Join(dir, "store")
|
||||
if err := InitExtensionRepoJSON(storeDir); err != nil {
|
||||
t.Fatalf("InitExtensionRepoJSON: %v", err)
|
||||
if err := InitExtensionStoreJSON(storeDir); err != nil {
|
||||
t.Fatalf("InitExtensionStoreJSON: %v", err)
|
||||
}
|
||||
if err := SetRepoRegistryURLJSON("https://registry.example.com/index.json"); err != nil {
|
||||
t.Fatalf("SetRepoRegistryURLJSON: %v", err)
|
||||
if err := SetStoreRegistryURLJSON("https://registry.example.com/index.json"); err != nil {
|
||||
t.Fatalf("SetStoreRegistryURLJSON: %v", err)
|
||||
}
|
||||
store := getExtensionRepo()
|
||||
store.cache = &repoRegistry{Extensions: []repoExtension{{
|
||||
store := getExtensionStore()
|
||||
store.cache = &storeRegistry{Extensions: []storeExtension{{
|
||||
ID: "coverage-ext",
|
||||
Name: "coverage-ext",
|
||||
Version: "1.0.0",
|
||||
@@ -436,44 +378,29 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
DownloadURL: "https://registry.example.com/coverage.spotiflac-ext",
|
||||
}}}
|
||||
store.cacheTime = time.Now()
|
||||
if registryURL, err := GetRepoRegistryURLJSON(); err != nil || registryURL == "" {
|
||||
t.Fatalf("GetRepoRegistryURLJSON = %q/%v", registryURL, err)
|
||||
if registryURL, err := GetStoreRegistryURLJSON(); err != nil || registryURL == "" {
|
||||
t.Fatalf("GetStoreRegistryURLJSON = %q/%v", registryURL, err)
|
||||
}
|
||||
if storeJSON, err := GetRepoExtensionsJSON(false); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
|
||||
t.Fatalf("GetRepoExtensionsJSON = %q/%v", storeJSON, err)
|
||||
if storeJSON, err := GetStoreExtensionsJSON(false); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
|
||||
t.Fatalf("GetStoreExtensionsJSON = %q/%v", storeJSON, err)
|
||||
}
|
||||
if storeJSON, err := SearchRepoExtensionsJSON("coverage", CategoryMetadata); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
|
||||
t.Fatalf("SearchRepoExtensionsJSON = %q/%v", storeJSON, err)
|
||||
if storeJSON, err := SearchStoreExtensionsJSON("coverage", CategoryMetadata); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
|
||||
t.Fatalf("SearchStoreExtensionsJSON = %q/%v", storeJSON, err)
|
||||
}
|
||||
if catsJSON, err := GetRepoCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
|
||||
t.Fatalf("GetRepoCategoriesJSON = %q/%v", catsJSON, err)
|
||||
if catsJSON, err := GetStoreCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
|
||||
t.Fatalf("GetStoreCategoriesJSON = %q/%v", catsJSON, err)
|
||||
}
|
||||
if dest, err := buildRepoExtensionDestPath(
|
||||
dir,
|
||||
"coverage/ext",
|
||||
"https://registry.example.com/coverage.spotiflac-ext",
|
||||
); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
|
||||
t.Fatalf("buildRepoExtensionDestPath = %q/%v", dest, err)
|
||||
if dest, err := buildStoreExtensionDestPath(dir, "coverage/ext"); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
|
||||
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
|
||||
}
|
||||
if dest, err := buildRepoExtensionDestPath(
|
||||
dir,
|
||||
"coverage/ext",
|
||||
"https://registry.example.com/coverage.sflx",
|
||||
); err != nil || !strings.HasSuffix(dest, ".sflx") {
|
||||
t.Fatalf("buildRepoExtensionDestPath sflx = %q/%v", dest, err)
|
||||
}
|
||||
if _, err := buildRepoExtensionDestPath(
|
||||
dir,
|
||||
" ",
|
||||
"https://registry.example.com/coverage.sflx",
|
||||
); err == nil {
|
||||
if _, err := buildStoreExtensionDestPath(dir, " "); err == nil {
|
||||
t.Fatal("expected invalid extension id")
|
||||
}
|
||||
if err := ClearRepoCacheJSON(); err != nil {
|
||||
t.Fatalf("ClearRepoCacheJSON: %v", err)
|
||||
if err := ClearStoreCacheJSON(); err != nil {
|
||||
t.Fatalf("ClearStoreCacheJSON: %v", err)
|
||||
}
|
||||
if err := ClearRepoRegistryURLJSON(); err != nil {
|
||||
t.Fatalf("ClearRepoRegistryURLJSON: %v", err)
|
||||
if err := ClearStoreRegistryURLJSON(); err != nil {
|
||||
t.Fatalf("ClearStoreRegistryURLJSON: %v", err)
|
||||
}
|
||||
|
||||
SetLibraryCoverCacheDirJSON(filepath.Join(dir, "covers"))
|
||||
@@ -504,6 +431,9 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
if metadataJSON, err := ReadAudioMetadataJSON(filepath.Join(libraryDir, "missing.mp3")); err != nil || metadataJSON == "" {
|
||||
t.Fatalf("ReadAudioMetadataJSON = %q/%v", metadataJSON, err)
|
||||
}
|
||||
if metadataJSON, err := ReadAudioMetadataWithHintJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing"); err != nil || metadataJSON == "" {
|
||||
t.Fatalf("ReadAudioMetadataWithHintJSON = %q/%v", metadataJSON, err)
|
||||
}
|
||||
if metadataJSON, err := ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filepath.Join(libraryDir, "missing.mp3"), "Missing", "key"); err != nil || metadataJSON == "" {
|
||||
t.Fatalf("ReadAudioMetadataWithHintAndCoverCacheKeyJSON = %q/%v", metadataJSON, err)
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMiss
|
||||
}
|
||||
|
||||
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
||||
AlbumName: "Original Album (Deluxe)",
|
||||
AlbumName: "Resolved Album",
|
||||
ReleaseDate: "",
|
||||
ISRC: "",
|
||||
})
|
||||
@@ -360,7 +360,7 @@ func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMiss
|
||||
if req.ReleaseDate != "2024-01-01" {
|
||||
t.Fatalf("release date = %q, want existing value preserved", req.ReleaseDate)
|
||||
}
|
||||
if req.AlbumName != "Original Album (Deluxe)" {
|
||||
if req.AlbumName != "Resolved Album" {
|
||||
t.Fatalf("album = %q, want updated album", req.AlbumName)
|
||||
}
|
||||
if req.ISRC != "REQ123" {
|
||||
@@ -368,43 +368,6 @@ func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMiss
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyReEnrichTrackMetadataKeepsReleaseIdentityOnAlbumMismatch(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Afsana",
|
||||
ArtistName: "Artist Name",
|
||||
AlbumName: "Original Soundtrack",
|
||||
CoverURL: "https://covers/original.jpg",
|
||||
TrackNumber: 3,
|
||||
ReleaseDate: "2005-01-01",
|
||||
}
|
||||
|
||||
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
||||
Name: "Afsana",
|
||||
Artists: "Artist Name",
|
||||
AlbumName: "The Hit Machine",
|
||||
CoverURL: "https://covers/compilation.jpg",
|
||||
TrackNumber: 17,
|
||||
ReleaseDate: "2010-01-01",
|
||||
ISRC: "NEW123",
|
||||
})
|
||||
|
||||
if req.AlbumName != "Original Soundtrack" {
|
||||
t.Fatalf("album = %q, want original release kept", req.AlbumName)
|
||||
}
|
||||
if req.CoverURL != "https://covers/original.jpg" {
|
||||
t.Fatalf("cover = %q, want original release cover kept", req.CoverURL)
|
||||
}
|
||||
if req.TrackNumber != 3 {
|
||||
t.Fatalf("track number = %d, want original position kept", req.TrackNumber)
|
||||
}
|
||||
if req.ReleaseDate != "2005-01-01" {
|
||||
t.Fatalf("release date = %q, want original date kept", req.ReleaseDate)
|
||||
}
|
||||
if req.ISRC != "NEW123" {
|
||||
t.Fatalf("isrc = %q, want recording-level fields still enriched", req.ISRC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Song Title",
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSimpleCookieJarReplacesAndExpiresCookies(t *testing.T) {
|
||||
jar, err := newSimpleCookieJar()
|
||||
if err != nil {
|
||||
t.Fatalf("newSimpleCookieJar: %v", err)
|
||||
}
|
||||
u, _ := url.Parse("https://api.example.com/path")
|
||||
|
||||
jar.SetCookies(u, []*http.Cookie{{Name: "session", Value: "old", Path: "/"}})
|
||||
jar.SetCookies(u, []*http.Cookie{{Name: "session", Value: "new", Path: "/"}})
|
||||
cookies := jar.Cookies(u)
|
||||
if len(cookies) != 1 || cookies[0].Value != "new" {
|
||||
t.Fatalf("replacement cookies = %#v", cookies)
|
||||
}
|
||||
|
||||
jar.SetCookies(u, []*http.Cookie{{
|
||||
Name: "session",
|
||||
Value: "expired",
|
||||
Path: "/",
|
||||
Expires: time.Now().Add(-time.Hour),
|
||||
MaxAge: -1,
|
||||
}})
|
||||
if cookies := jar.Cookies(u); len(cookies) != 0 {
|
||||
t.Fatalf("expired cookies = %#v", cookies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleCookieJarClearAndScope(t *testing.T) {
|
||||
jar, err := newSimpleCookieJar()
|
||||
if err != nil {
|
||||
t.Fatalf("newSimpleCookieJar: %v", err)
|
||||
}
|
||||
apiURL, _ := url.Parse("https://api.example.com/private/resource")
|
||||
otherURL, _ := url.Parse("https://other.example.com/private/resource")
|
||||
publicURL, _ := url.Parse("https://api.example.com/public")
|
||||
|
||||
jar.SetCookies(apiURL, []*http.Cookie{{Name: "session", Value: "value", Path: "/private"}})
|
||||
if len(jar.Cookies(apiURL)) != 1 {
|
||||
t.Fatal("expected cookie for matching host and path")
|
||||
}
|
||||
if len(jar.Cookies(otherURL)) != 0 || len(jar.Cookies(publicURL)) != 0 {
|
||||
t.Fatal("cookie escaped its host or path scope")
|
||||
}
|
||||
|
||||
jar.Clear()
|
||||
if len(jar.Cookies(apiURL)) != 0 {
|
||||
t.Fatal("Clear retained cookies")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,122 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVerifiedDownloadResumeTriesSelectedProviderBeforeMetadata(t *testing.T) {
|
||||
resetPreparedDownloadRequestCacheForTest()
|
||||
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
|
||||
|
||||
metadataExt := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
|
||||
metadataExt.ID = "resume-metadata"
|
||||
metadataExt.Manifest.Name = metadataExt.ID
|
||||
downloadExt := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||
downloadExt.ID = "resume-download"
|
||||
downloadExt.Manifest.Name = downloadExt.ID
|
||||
|
||||
manager := getExtensionManager()
|
||||
manager.mu.Lock()
|
||||
previousExtensions := manager.extensions
|
||||
manager.extensions = map[string]*loadedExtension{
|
||||
metadataExt.ID: metadataExt,
|
||||
downloadExt.ID: downloadExt,
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
teardownExtension(metadataExt)
|
||||
teardownExtension(downloadExt)
|
||||
manager.mu.Lock()
|
||||
manager.extensions = previousExtensions
|
||||
manager.mu.Unlock()
|
||||
})
|
||||
|
||||
req := DownloadRequest{
|
||||
ItemID: "resume-item",
|
||||
Service: downloadExt.ID,
|
||||
Source: metadataExt.ID,
|
||||
SpotifyID: "spotify:track:1",
|
||||
TrackName: "Original Song",
|
||||
ArtistName: "Artist",
|
||||
AlbumName: "Album",
|
||||
ReleaseDate: "2026-05-04",
|
||||
OutputDir: t.TempDir(),
|
||||
OutputExt: ".flac",
|
||||
FilenameFormat: "{title}",
|
||||
Quality: "LOSSLESS",
|
||||
UseFallback: false,
|
||||
}
|
||||
key := downloadPreparationKey(req)
|
||||
cacheUnpreparedDownloadRequest(key, req)
|
||||
|
||||
resp, err := DownloadWithExtensionFallback(req)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadWithExtensionFallback: %v", err)
|
||||
}
|
||||
if resp == nil || !resp.Success {
|
||||
t.Fatalf("resume response = %#v", resp)
|
||||
}
|
||||
if got := filepath.Base(resp.FilePath); got != "Original Song.flac" {
|
||||
t.Fatalf("resume ran metadata enrichment before download: file = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifiedDownloadResumeReusesPreparedMetadata(t *testing.T) {
|
||||
resetPreparedDownloadRequestCacheForTest()
|
||||
t.Cleanup(resetPreparedDownloadRequestCacheForTest)
|
||||
|
||||
metadataExt := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
|
||||
metadataExt.ID = "prepared-metadata"
|
||||
metadataExt.Manifest.Name = metadataExt.ID
|
||||
downloadExt := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||
downloadExt.ID = "prepared-download"
|
||||
downloadExt.Manifest.Name = downloadExt.ID
|
||||
|
||||
manager := getExtensionManager()
|
||||
manager.mu.Lock()
|
||||
previousExtensions := manager.extensions
|
||||
manager.extensions = map[string]*loadedExtension{
|
||||
metadataExt.ID: metadataExt,
|
||||
downloadExt.ID: downloadExt,
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
teardownExtension(metadataExt)
|
||||
teardownExtension(downloadExt)
|
||||
manager.mu.Lock()
|
||||
manager.extensions = previousExtensions
|
||||
manager.mu.Unlock()
|
||||
})
|
||||
|
||||
req := DownloadRequest{
|
||||
ItemID: "prepared-item",
|
||||
Service: downloadExt.ID,
|
||||
Source: metadataExt.ID,
|
||||
SpotifyID: "spotify:track:2",
|
||||
TrackName: "Original Song",
|
||||
ArtistName: "Artist",
|
||||
AlbumName: "Album",
|
||||
ReleaseDate: "2026-05-04",
|
||||
OutputDir: t.TempDir(),
|
||||
OutputExt: ".flac",
|
||||
FilenameFormat: "{title}",
|
||||
Quality: "LOSSLESS",
|
||||
UseFallback: false,
|
||||
}
|
||||
key := downloadPreparationKey(req)
|
||||
prepared := req
|
||||
prepared.TrackName = "Prepared Song"
|
||||
cachePreparedDownloadRequest(key, prepared)
|
||||
|
||||
resp, err := DownloadWithExtensionFallback(req)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadWithExtensionFallback: %v", err)
|
||||
}
|
||||
if resp == nil || !resp.Success {
|
||||
t.Fatalf("prepared response = %#v", resp)
|
||||
}
|
||||
if got := filepath.Base(resp.FilePath); got != "Prepared Song.flac" {
|
||||
t.Fatalf("prepared metadata was not reused: file = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,647 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func gojaValueIsEmpty(value goja.Value) bool {
|
||||
return value == nil || goja.IsUndefined(value) || goja.IsNull(value)
|
||||
}
|
||||
|
||||
func gojaObjectString(obj *goja.Object, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
value := obj.Get(key)
|
||||
if gojaValueIsEmpty(value) {
|
||||
continue
|
||||
}
|
||||
if str, ok := value.Export().(string); ok {
|
||||
return str
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func gojaObjectValue(obj *goja.Object, keys ...string) goja.Value {
|
||||
for _, key := range keys {
|
||||
value := obj.Get(key)
|
||||
if !gojaValueIsEmpty(value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func gojaObjectInt(obj *goja.Object, keys ...string) int {
|
||||
for _, key := range keys {
|
||||
value := obj.Get(key)
|
||||
if gojaValueIsEmpty(value) {
|
||||
continue
|
||||
}
|
||||
return int(value.ToInteger())
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func gojaObjectInt64(obj *goja.Object, keys ...string) int64 {
|
||||
for _, key := range keys {
|
||||
value := obj.Get(key)
|
||||
if gojaValueIsEmpty(value) {
|
||||
continue
|
||||
}
|
||||
return value.ToInteger()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func gojaObjectBool(obj *goja.Object, keys ...string) bool {
|
||||
for _, key := range keys {
|
||||
value := obj.Get(key)
|
||||
if gojaValueIsEmpty(value) {
|
||||
continue
|
||||
}
|
||||
return value.ToBoolean()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func gojaObjectInterfaceMap(obj *goja.Object, keys ...string) map[string]any {
|
||||
value := gojaObjectValue(obj, keys...)
|
||||
if gojaValueIsEmpty(value) {
|
||||
return nil
|
||||
}
|
||||
|
||||
exported, ok := value.Export().(map[string]any)
|
||||
if !ok || len(exported) == 0 {
|
||||
return nil
|
||||
}
|
||||
return exported
|
||||
}
|
||||
|
||||
func gojaObjectStringMap(vm *goja.Runtime, obj *goja.Object, keys ...string) map[string]string {
|
||||
value := gojaObjectValue(obj, keys...)
|
||||
if gojaValueIsEmpty(value) {
|
||||
return nil
|
||||
}
|
||||
|
||||
valueObj := value.ToObject(vm)
|
||||
objectKeys := valueObj.Keys()
|
||||
if len(objectKeys) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[string]string, len(objectKeys))
|
||||
for _, childKey := range objectKeys {
|
||||
childValue := valueObj.Get(childKey)
|
||||
if gojaValueIsEmpty(childValue) {
|
||||
continue
|
||||
}
|
||||
result[childKey] = childValue.String()
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func gojaObjectStringSlice(obj *goja.Object, keys ...string) []string {
|
||||
value := gojaObjectValue(obj, keys...)
|
||||
if gojaValueIsEmpty(value) {
|
||||
return nil
|
||||
}
|
||||
exported, ok := value.Export().([]any)
|
||||
if !ok || len(exported) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(exported))
|
||||
for _, item := range exported {
|
||||
str, ok := item.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
str = strings.TrimSpace(str)
|
||||
if str != "" {
|
||||
result = append(result, str)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func gojaArrayLength(value goja.Value, vm *goja.Runtime) (int, error) {
|
||||
if gojaValueIsEmpty(value) {
|
||||
return 0, nil
|
||||
}
|
||||
lengthValue := value.ToObject(vm).Get("length")
|
||||
if gojaValueIsEmpty(lengthValue) {
|
||||
return 0, fmt.Errorf("value is not an array")
|
||||
}
|
||||
length := lengthValue.ToInteger()
|
||||
if length <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return int(length), nil
|
||||
}
|
||||
|
||||
func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetadata {
|
||||
obj := value.ToObject(vm)
|
||||
return ExtTrackMetadata{
|
||||
ID: gojaObjectString(obj, "id"),
|
||||
Name: gojaObjectString(obj, "name"),
|
||||
Artists: gojaObjectString(obj, "artists"),
|
||||
AlbumName: gojaObjectString(obj, "album_name", "albumName"),
|
||||
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
|
||||
AlbumID: gojaObjectString(obj, "album_id", "albumId"),
|
||||
AlbumURL: gojaObjectString(obj, "album_url", "albumUrl"),
|
||||
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
|
||||
ArtistURL: gojaObjectString(obj, "artist_url", "artistUrl"),
|
||||
ExternalURL: gojaObjectString(obj, "external_urls", "externalUrls", "external_url", "externalUrl", "url"),
|
||||
DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"),
|
||||
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
||||
PreviewURL: gojaObjectString(obj, "preview_url", "previewUrl"),
|
||||
Images: gojaObjectString(obj, "images"),
|
||||
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
|
||||
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
|
||||
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
|
||||
DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"),
|
||||
TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"),
|
||||
ISRC: gojaObjectString(obj, "isrc"),
|
||||
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
|
||||
ItemType: gojaObjectString(obj, "item_type", "itemType"),
|
||||
AlbumType: gojaObjectString(obj, "album_type", "albumType"),
|
||||
Explicit: gojaObjectBool(obj, "explicit", "is_explicit", "isExplicit"),
|
||||
TidalID: gojaObjectString(obj, "tidal_id", "tidalId"),
|
||||
QobuzID: gojaObjectString(obj, "qobuz_id", "qobuzId"),
|
||||
DeezerID: gojaObjectString(obj, "deezer_id", "deezerId"),
|
||||
SpotifyID: gojaObjectString(obj, "spotify_id", "spotifyId"),
|
||||
ExternalLinks: gojaObjectStringMap(vm, obj, "external_links", "externalLinks"),
|
||||
Label: gojaObjectString(obj, "label"),
|
||||
Copyright: gojaObjectString(obj, "copyright"),
|
||||
Genre: gojaObjectString(obj, "genre"),
|
||||
Composer: gojaObjectString(obj, "composer"),
|
||||
AudioQuality: gojaObjectString(obj, "audio_quality", "audioQuality"),
|
||||
AudioModes: gojaObjectString(obj, "audio_modes", "audioModes"),
|
||||
}
|
||||
}
|
||||
|
||||
func parseExtensionTrackArray(vm *goja.Runtime, value goja.Value) ([]ExtTrackMetadata, error) {
|
||||
length, err := gojaArrayLength(value, vm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if length == 0 {
|
||||
return []ExtTrackMetadata{}, nil
|
||||
}
|
||||
|
||||
arrayObj := value.ToObject(vm)
|
||||
tracks := make([]ExtTrackMetadata, 0, length)
|
||||
for i := 0; i < length; i++ {
|
||||
trackValue := arrayObj.Get(strconv.Itoa(i))
|
||||
if gojaValueIsEmpty(trackValue) {
|
||||
continue
|
||||
}
|
||||
tracks = append(tracks, parseExtensionTrackValue(vm, trackValue))
|
||||
}
|
||||
return tracks, nil
|
||||
}
|
||||
|
||||
func parseExtensionAlbumValue(vm *goja.Runtime, value goja.Value) (ExtAlbumMetadata, error) {
|
||||
if gojaValueIsEmpty(value) {
|
||||
return ExtAlbumMetadata{}, nil
|
||||
}
|
||||
|
||||
obj := value.ToObject(vm)
|
||||
tracks := []ExtTrackMetadata{}
|
||||
if tracksValue := gojaObjectValue(obj, "tracks"); !gojaValueIsEmpty(tracksValue) {
|
||||
parsedTracks, err := parseExtensionTrackArray(vm, tracksValue)
|
||||
if err != nil {
|
||||
return ExtAlbumMetadata{}, err
|
||||
}
|
||||
tracks = parsedTracks
|
||||
}
|
||||
|
||||
return ExtAlbumMetadata{
|
||||
ID: gojaObjectString(obj, "id"),
|
||||
Name: gojaObjectString(obj, "name"),
|
||||
Artists: gojaObjectString(obj, "artists"),
|
||||
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
|
||||
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl", "images"),
|
||||
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
|
||||
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
|
||||
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
|
||||
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
|
||||
AlbumType: gojaObjectString(obj, "album_type", "albumType"),
|
||||
AudioTraits: gojaObjectStringSlice(obj, "audio_traits", "audioTraits"),
|
||||
Tracks: tracks,
|
||||
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
|
||||
}.withTrackFallbacks(), nil
|
||||
}
|
||||
|
||||
// withTrackFallbacks fills the album-level artist and release date from the
|
||||
// album's tracks when the extension did not provide them at the album level.
|
||||
// This is a generic mechanism so any extension benefits, without per-extension
|
||||
// special-casing in the app.
|
||||
func (a ExtAlbumMetadata) withTrackFallbacks() ExtAlbumMetadata {
|
||||
if strings.TrimSpace(a.Artists) == "" {
|
||||
a.Artists = albumArtistFromTracks(a.Tracks)
|
||||
}
|
||||
if strings.TrimSpace(a.ReleaseDate) == "" {
|
||||
a.ReleaseDate = albumReleaseDateFromTracks(a.Tracks)
|
||||
}
|
||||
if len(a.AudioTraits) == 0 {
|
||||
a.AudioTraits = albumAudioTraitsFromTracks(a.Tracks)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// albumArtistFromTracks prefers an explicit per-track album artist, then falls
|
||||
// back to the most common track artist across the album.
|
||||
func albumArtistFromTracks(tracks []ExtTrackMetadata) string {
|
||||
for _, t := range tracks {
|
||||
if s := strings.TrimSpace(t.AlbumArtist); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
counts := map[string]int{}
|
||||
order := []string{}
|
||||
for _, t := range tracks {
|
||||
artist := strings.TrimSpace(t.Artists)
|
||||
if artist == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := counts[artist]; !ok {
|
||||
order = append(order, artist)
|
||||
}
|
||||
counts[artist]++
|
||||
}
|
||||
best := ""
|
||||
bestCount := 0
|
||||
for _, artist := range order {
|
||||
if counts[artist] > bestCount {
|
||||
best = artist
|
||||
bestCount = counts[artist]
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// albumReleaseDateFromTracks returns the first non-empty track release date.
|
||||
func albumReleaseDateFromTracks(tracks []ExtTrackMetadata) string {
|
||||
for _, t := range tracks {
|
||||
if s := strings.TrimSpace(t.ReleaseDate); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// albumAudioTraitsFromTracks derives album-level audio badges (Dolby Atmos,
|
||||
// Hi-Res Lossless, Lossless) from the per-track audio quality/mode fields that
|
||||
// extensions like Tidal and Qobuz already provide. Tokens match what the album
|
||||
// header understands ("dolby_atmos", "hi_res_lossless", "lossless").
|
||||
func albumAudioTraitsFromTracks(tracks []ExtTrackMetadata) []string {
|
||||
atmos := false
|
||||
hiRes := false
|
||||
lossless := false
|
||||
|
||||
for _, t := range tracks {
|
||||
modes := strings.ToUpper(t.AudioModes)
|
||||
quality := strings.ToUpper(t.AudioQuality)
|
||||
if strings.Contains(modes, "ATMOS") || strings.Contains(quality, "ATMOS") {
|
||||
atmos = true
|
||||
}
|
||||
if strings.Contains(quality, "HI_RES") ||
|
||||
strings.Contains(quality, "HIRES") ||
|
||||
strings.Contains(quality, "MASTER") ||
|
||||
strings.Contains(quality, "MQA") {
|
||||
hiRes = true
|
||||
}
|
||||
if strings.Contains(quality, "LOSSLESS") ||
|
||||
strings.Contains(quality, "FLAC") {
|
||||
lossless = true
|
||||
}
|
||||
if bd, sr := parseBitDepthSampleRate(quality); bd > 0 {
|
||||
if bd > 16 || sr > 48 {
|
||||
hiRes = true
|
||||
} else {
|
||||
lossless = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traits := []string{}
|
||||
if atmos {
|
||||
traits = append(traits, "dolby_atmos")
|
||||
}
|
||||
if hiRes {
|
||||
traits = append(traits, "hi_res_lossless")
|
||||
} else if lossless {
|
||||
traits = append(traits, "lossless")
|
||||
}
|
||||
return traits
|
||||
}
|
||||
|
||||
// parseBitDepthSampleRate extracts a bit depth and sample rate (in kHz) from
|
||||
// labels such as "24bit/96kHz", "16bit/44.1kHz" or "24bit".
|
||||
func parseBitDepthSampleRate(quality string) (int, float64) {
|
||||
lower := strings.ToLower(quality)
|
||||
bitDepth := 0
|
||||
sampleRate := 0.0
|
||||
|
||||
if idx := strings.Index(lower, "bit"); idx > 0 {
|
||||
j := idx
|
||||
for j > 0 && lower[j-1] >= '0' && lower[j-1] <= '9' {
|
||||
j--
|
||||
}
|
||||
if n, err := strconv.Atoi(lower[j:idx]); err == nil {
|
||||
bitDepth = n
|
||||
}
|
||||
}
|
||||
if idx := strings.Index(lower, "khz"); idx > 0 {
|
||||
j := idx
|
||||
for j > 0 && ((lower[j-1] >= '0' && lower[j-1] <= '9') || lower[j-1] == '.') {
|
||||
j--
|
||||
}
|
||||
if f, err := strconv.ParseFloat(lower[j:idx], 64); err == nil {
|
||||
sampleRate = f
|
||||
}
|
||||
}
|
||||
return bitDepth, sampleRate
|
||||
}
|
||||
|
||||
func parseExtensionAlbumArray(vm *goja.Runtime, value goja.Value) ([]ExtAlbumMetadata, error) {
|
||||
length, err := gojaArrayLength(value, vm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if length == 0 {
|
||||
return []ExtAlbumMetadata{}, nil
|
||||
}
|
||||
|
||||
arrayObj := value.ToObject(vm)
|
||||
albums := make([]ExtAlbumMetadata, 0, length)
|
||||
for i := 0; i < length; i++ {
|
||||
albumValue := arrayObj.Get(strconv.Itoa(i))
|
||||
if gojaValueIsEmpty(albumValue) {
|
||||
continue
|
||||
}
|
||||
album, err := parseExtensionAlbumValue(vm, albumValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
albums = append(albums, album)
|
||||
}
|
||||
return albums, nil
|
||||
}
|
||||
|
||||
func parseExtensionArtistValue(vm *goja.Runtime, value goja.Value) (ExtArtistMetadata, error) {
|
||||
if gojaValueIsEmpty(value) {
|
||||
return ExtArtistMetadata{}, nil
|
||||
}
|
||||
|
||||
obj := value.ToObject(vm)
|
||||
albums := []ExtAlbumMetadata{}
|
||||
if albumsValue := gojaObjectValue(obj, "albums"); !gojaValueIsEmpty(albumsValue) {
|
||||
parsedAlbums, err := parseExtensionAlbumArray(vm, albumsValue)
|
||||
if err != nil {
|
||||
return ExtArtistMetadata{}, err
|
||||
}
|
||||
albums = parsedAlbums
|
||||
}
|
||||
|
||||
releases := []ExtAlbumMetadata{}
|
||||
if releasesValue := gojaObjectValue(obj, "releases"); !gojaValueIsEmpty(releasesValue) {
|
||||
parsedReleases, err := parseExtensionAlbumArray(vm, releasesValue)
|
||||
if err != nil {
|
||||
return ExtArtistMetadata{}, err
|
||||
}
|
||||
releases = parsedReleases
|
||||
}
|
||||
|
||||
topTracks := []ExtTrackMetadata{}
|
||||
if topTracksValue := gojaObjectValue(obj, "top_tracks", "topTracks"); !gojaValueIsEmpty(topTracksValue) {
|
||||
parsedTopTracks, err := parseExtensionTrackArray(vm, topTracksValue)
|
||||
if err != nil {
|
||||
return ExtArtistMetadata{}, err
|
||||
}
|
||||
topTracks = parsedTopTracks
|
||||
}
|
||||
|
||||
return ExtArtistMetadata{
|
||||
ID: gojaObjectString(obj, "id"),
|
||||
Name: gojaObjectString(obj, "name"),
|
||||
ImageURL: gojaObjectString(obj, "image_url", "imageUrl"),
|
||||
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
|
||||
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
|
||||
Listeners: gojaObjectInt(obj, "listeners"),
|
||||
Albums: albums,
|
||||
Releases: releases,
|
||||
TopTracks: topTracks,
|
||||
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseExtensionAvailabilityValue(vm *goja.Runtime, value goja.Value) ExtAvailabilityResult {
|
||||
obj := value.ToObject(vm)
|
||||
return ExtAvailabilityResult{
|
||||
Available: gojaObjectBool(obj, "available"),
|
||||
Reason: gojaObjectString(obj, "reason"),
|
||||
TrackID: gojaObjectString(obj, "track_id", "trackId"),
|
||||
SkipFallback: gojaObjectBool(obj, "skip_fallback", "skipFallback"),
|
||||
}
|
||||
}
|
||||
|
||||
func parseExtensionDownloadDecryptionValue(vm *goja.Runtime, value goja.Value) *DownloadDecryptionInfo {
|
||||
if gojaValueIsEmpty(value) {
|
||||
return nil
|
||||
}
|
||||
|
||||
obj := value.ToObject(vm)
|
||||
info := &DownloadDecryptionInfo{
|
||||
Strategy: gojaObjectString(obj, "strategy"),
|
||||
Key: gojaObjectString(obj, "key"),
|
||||
IV: gojaObjectString(obj, "iv"),
|
||||
InputFormat: gojaObjectString(obj, "input_format", "inputFormat"),
|
||||
OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"),
|
||||
Options: gojaObjectInterfaceMap(obj, "options"),
|
||||
}
|
||||
if info.Strategy == "" && info.Key == "" && info.IV == "" && info.InputFormat == "" && info.OutputExtension == "" && len(info.Options) == 0 {
|
||||
return nil
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDownloadResult {
|
||||
obj := value.ToObject(vm)
|
||||
return ExtDownloadResult{
|
||||
Success: gojaObjectBool(obj, "success"),
|
||||
FilePath: gojaObjectString(obj, "file_path", "filePath", "path"),
|
||||
AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"),
|
||||
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
|
||||
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
|
||||
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
|
||||
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
|
||||
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
|
||||
RetryAfterSeconds: gojaObjectInt(obj, "retry_after_seconds", "retryAfterSeconds"),
|
||||
Title: gojaObjectString(obj, "title"),
|
||||
Artist: gojaObjectString(obj, "artist"),
|
||||
Album: gojaObjectString(obj, "album"),
|
||||
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
|
||||
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
|
||||
DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"),
|
||||
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
|
||||
TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"),
|
||||
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
|
||||
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
||||
ISRC: gojaObjectString(obj, "isrc"),
|
||||
Genre: gojaObjectString(obj, "genre"),
|
||||
Label: gojaObjectString(obj, "label"),
|
||||
Copyright: gojaObjectString(obj, "copyright"),
|
||||
Composer: gojaObjectString(obj, "composer"),
|
||||
LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"),
|
||||
DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"),
|
||||
Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")),
|
||||
ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"),
|
||||
OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"),
|
||||
ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"),
|
||||
RequiresContainerConversion: gojaObjectBool(
|
||||
obj,
|
||||
"requires_container_conversion",
|
||||
"requiresContainerConversion",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func parseExtensionURLHandleValue(vm *goja.Runtime, value goja.Value) (ExtURLHandleResult, error) {
|
||||
obj := value.ToObject(vm)
|
||||
handleResult := ExtURLHandleResult{
|
||||
Type: gojaObjectString(obj, "type"),
|
||||
Name: gojaObjectString(obj, "name"),
|
||||
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
||||
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
|
||||
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
|
||||
}
|
||||
|
||||
if trackValue := gojaObjectValue(obj, "track"); !gojaValueIsEmpty(trackValue) {
|
||||
track := parseExtensionTrackValue(vm, trackValue)
|
||||
handleResult.Track = &track
|
||||
}
|
||||
if tracksValue := gojaObjectValue(obj, "tracks"); !gojaValueIsEmpty(tracksValue) {
|
||||
tracks, err := parseExtensionTrackArray(vm, tracksValue)
|
||||
if err != nil {
|
||||
return ExtURLHandleResult{}, err
|
||||
}
|
||||
handleResult.Tracks = tracks
|
||||
}
|
||||
if albumValue := gojaObjectValue(obj, "album"); !gojaValueIsEmpty(albumValue) {
|
||||
album, err := parseExtensionAlbumValue(vm, albumValue)
|
||||
if err != nil {
|
||||
return ExtURLHandleResult{}, err
|
||||
}
|
||||
handleResult.Album = &album
|
||||
}
|
||||
if artistValue := gojaObjectValue(obj, "artist"); !gojaValueIsEmpty(artistValue) {
|
||||
artist, err := parseExtensionArtistValue(vm, artistValue)
|
||||
if err != nil {
|
||||
return ExtURLHandleResult{}, err
|
||||
}
|
||||
handleResult.Artist = &artist
|
||||
}
|
||||
|
||||
return handleResult, nil
|
||||
}
|
||||
|
||||
func parseExtensionPostProcessValue(vm *goja.Runtime, value goja.Value) PostProcessResult {
|
||||
obj := value.ToObject(vm)
|
||||
return PostProcessResult{
|
||||
Success: gojaObjectBool(obj, "success"),
|
||||
NewFilePath: gojaObjectString(obj, "new_file_path", "newFilePath"),
|
||||
NewFileURI: gojaObjectString(obj, "new_file_uri", "newFileUri"),
|
||||
Error: gojaObjectString(obj, "error"),
|
||||
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
|
||||
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
|
||||
}
|
||||
}
|
||||
|
||||
func parseExtensionLyricsLineArray(vm *goja.Runtime, value goja.Value) ([]ExtLyricsLine, error) {
|
||||
length, err := gojaArrayLength(value, vm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if length == 0 {
|
||||
return []ExtLyricsLine{}, nil
|
||||
}
|
||||
|
||||
arrayObj := value.ToObject(vm)
|
||||
lines := make([]ExtLyricsLine, 0, length)
|
||||
for i := 0; i < length; i++ {
|
||||
lineValue := arrayObj.Get(strconv.Itoa(i))
|
||||
if gojaValueIsEmpty(lineValue) {
|
||||
continue
|
||||
}
|
||||
lineObj := lineValue.ToObject(vm)
|
||||
lines = append(lines, ExtLyricsLine{
|
||||
StartTimeMs: gojaObjectInt64(lineObj, "startTimeMs", "start_time_ms"),
|
||||
Words: gojaObjectString(lineObj, "words"),
|
||||
EndTimeMs: gojaObjectInt64(lineObj, "endTimeMs", "end_time_ms"),
|
||||
})
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
func parseExtensionLyricsValue(vm *goja.Runtime, value goja.Value) (ExtLyricsResult, error) {
|
||||
obj := value.ToObject(vm)
|
||||
lines := []ExtLyricsLine{}
|
||||
if linesValue := gojaObjectValue(obj, "lines"); !gojaValueIsEmpty(linesValue) {
|
||||
parsedLines, err := parseExtensionLyricsLineArray(vm, linesValue)
|
||||
if err != nil {
|
||||
return ExtLyricsResult{}, err
|
||||
}
|
||||
lines = parsedLines
|
||||
}
|
||||
|
||||
return ExtLyricsResult{
|
||||
Lines: lines,
|
||||
SyncType: gojaObjectString(obj, "syncType", "sync_type"),
|
||||
Instrumental: gojaObjectBool(obj, "instrumental"),
|
||||
PlainLyrics: gojaObjectString(obj, "plainLyrics", "plain_lyrics"),
|
||||
Provider: gojaObjectString(obj, "provider"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseExtensionSearchResult(vm *goja.Runtime, value goja.Value) (ExtSearchResult, error) {
|
||||
if gojaValueIsEmpty(value) {
|
||||
return ExtSearchResult{}, nil
|
||||
}
|
||||
|
||||
resultObj := value.ToObject(vm)
|
||||
tracksValue := resultObj.Get("tracks")
|
||||
if gojaValueIsEmpty(tracksValue) {
|
||||
tracks, err := parseExtensionTrackArray(vm, value)
|
||||
if err != nil {
|
||||
return ExtSearchResult{}, err
|
||||
}
|
||||
return ExtSearchResult{
|
||||
Tracks: tracks,
|
||||
Total: len(tracks),
|
||||
}, nil
|
||||
}
|
||||
|
||||
tracks, err := parseExtensionTrackArray(vm, tracksValue)
|
||||
if err != nil {
|
||||
return ExtSearchResult{}, err
|
||||
}
|
||||
total := gojaObjectInt(resultObj, "total")
|
||||
if total == 0 {
|
||||
total = len(tracks)
|
||||
}
|
||||
return ExtSearchResult{
|
||||
Tracks: tracks,
|
||||
Total: total,
|
||||
}, nil
|
||||
}
|
||||
@@ -15,9 +15,7 @@ import (
|
||||
const (
|
||||
extensionHealthDefaultTimeout = 4 * time.Second
|
||||
extensionHealthMaxBodyBytes = 64 * 1024
|
||||
extensionHealthDefaultCache = 10 * time.Minute
|
||||
extensionHealthMinCache = 60 * time.Second
|
||||
extensionHealthUnknownCache = 2 * time.Minute
|
||||
extensionHealthDefaultCache = 60 * time.Second
|
||||
)
|
||||
|
||||
type ExtensionHealthResult struct {
|
||||
@@ -52,12 +50,6 @@ var (
|
||||
extensionHealthCache = map[string]cachedExtensionHealthResult{}
|
||||
)
|
||||
|
||||
func clearExtensionHealthCache() {
|
||||
extensionHealthCacheMu.Lock()
|
||||
extensionHealthCache = map[string]cachedExtensionHealthResult{}
|
||||
extensionHealthCacheMu.Unlock()
|
||||
}
|
||||
|
||||
func CheckExtensionHealthJSON(extensionID string) (string, error) {
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
@@ -66,7 +58,6 @@ func CheckExtensionHealthJSON(extensionID string) (string, error) {
|
||||
}
|
||||
|
||||
result := CheckExtensionHealth(ext)
|
||||
cacheExtensionHealthResult(ext, result)
|
||||
bytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -94,31 +85,16 @@ func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
|
||||
extensionHealthCacheMu.Unlock()
|
||||
|
||||
result := CheckExtensionHealth(ext)
|
||||
cacheExtensionHealthResult(ext, result)
|
||||
return result
|
||||
}
|
||||
|
||||
func cacheExtensionHealthResult(ext *loadedExtension, result ExtensionHealthResult) {
|
||||
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cacheKey := strings.TrimSpace(ext.ID)
|
||||
if cacheKey == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth)
|
||||
if result.Status == "unknown" && ttl > extensionHealthUnknownCache {
|
||||
ttl = extensionHealthUnknownCache
|
||||
}
|
||||
|
||||
extensionHealthCacheMu.Lock()
|
||||
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
|
||||
result: result,
|
||||
expiresAt: time.Now().Add(ttl),
|
||||
expiresAt: now.Add(ttl),
|
||||
}
|
||||
extensionHealthCacheMu.Unlock()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
|
||||
@@ -173,9 +149,6 @@ func extensionHealthCacheTTL(checks []ExtensionHealthCheck) time.Duration {
|
||||
continue
|
||||
}
|
||||
checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second
|
||||
if checkTTL < extensionHealthMinCache {
|
||||
checkTTL = extensionHealthMinCache
|
||||
}
|
||||
if checkTTL < ttl {
|
||||
ttl = checkTTL
|
||||
}
|
||||
@@ -253,11 +226,7 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC
|
||||
resp, err := NewMetadataHTTPClient(timeout).Do(req)
|
||||
result.LatencyMs = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
if isTransientExtensionHealthError(err) {
|
||||
result.Status = "unknown"
|
||||
} else {
|
||||
result.Status = "offline"
|
||||
}
|
||||
result.Status = "offline"
|
||||
result.Error = err.Error()
|
||||
return result
|
||||
}
|
||||
@@ -293,16 +262,12 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC
|
||||
return result
|
||||
}
|
||||
|
||||
func isTransientExtensionHealthError(err error) bool {
|
||||
return isTransientNetworkError(err) || isConnectivityFailure(err)
|
||||
}
|
||||
|
||||
func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string) {
|
||||
if len(strings.TrimSpace(string(body))) == 0 {
|
||||
return "online", ""
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "online", ""
|
||||
}
|
||||
@@ -322,21 +287,18 @@ func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string
|
||||
case "degraded", "partial", "warning", "warn":
|
||||
return "degraded", rawStatus
|
||||
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
||||
if isTransientHealthStatusMessage(string(body)) {
|
||||
return "unknown", rawStatus
|
||||
}
|
||||
return "offline", rawStatus
|
||||
default:
|
||||
return "online", rawStatus
|
||||
}
|
||||
}
|
||||
|
||||
func classifyExtensionHealthService(payload map[string]any, serviceKey string) (string, string, bool) {
|
||||
func classifyExtensionHealthService(payload map[string]interface{}, serviceKey string) (string, string, bool) {
|
||||
rawServices, ok := payload["services"]
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
services, ok := rawServices.(map[string]any)
|
||||
services, ok := rawServices.(map[string]interface{})
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
@@ -344,7 +306,7 @@ func classifyExtensionHealthService(payload map[string]any, serviceKey string) (
|
||||
if !ok {
|
||||
return "unknown", fmt.Sprintf("service '%s' not found", serviceKey), true
|
||||
}
|
||||
service, ok := rawService.(map[string]any)
|
||||
service, ok := rawService.(map[string]interface{})
|
||||
if !ok {
|
||||
return "unknown", fmt.Sprintf("service '%s' has invalid health payload", serviceKey), true
|
||||
}
|
||||
@@ -365,53 +327,42 @@ func classifyExtensionHealthService(payload map[string]any, serviceKey string) (
|
||||
|
||||
rawStatus, hasStatus := service["status"]
|
||||
okValue, hasOK := service["ok"].(bool)
|
||||
joinedMessage := strings.Join(messageParts, ": ")
|
||||
transient := isTransientHealthStatusMessage(detail) ||
|
||||
isTransientHealthStatusMessage(errText) ||
|
||||
isTransientHealthStatusMessage(label)
|
||||
|
||||
if statusCode, ok := healthNumber(rawStatus); ok {
|
||||
if statusCode >= 200 && statusCode < 300 {
|
||||
return "online", joinedMessage, true
|
||||
return "online", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
|
||||
return "degraded", joinedMessage, true
|
||||
return "degraded", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
if statusCode == http.StatusInternalServerError && hasOK && okValue {
|
||||
return "online", joinedMessage, true
|
||||
return "online", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
if transient || isTransientHealthStatusCode(statusCode) {
|
||||
return "unknown", joinedMessage, true
|
||||
}
|
||||
return "offline", joinedMessage, true
|
||||
return "offline", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
|
||||
if isExtensionHealthAuthRequired(detail) {
|
||||
return "degraded", joinedMessage, true
|
||||
}
|
||||
if transient {
|
||||
return "unknown", joinedMessage, true
|
||||
return "degraded", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
if hasOK {
|
||||
if okValue {
|
||||
return "online", joinedMessage, true
|
||||
return "online", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
return "offline", joinedMessage, true
|
||||
return "offline", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
if !hasStatus {
|
||||
return "unknown", joinedMessage, true
|
||||
return "unknown", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
|
||||
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
|
||||
switch statusString {
|
||||
case "ok", "up", "online", "healthy", "operational":
|
||||
return "online", joinedMessage, true
|
||||
return "online", strings.Join(messageParts, ": "), true
|
||||
case "degraded", "partial", "warning", "warn":
|
||||
return "degraded", joinedMessage, true
|
||||
return "degraded", strings.Join(messageParts, ": "), true
|
||||
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
||||
return "offline", joinedMessage, true
|
||||
return "offline", strings.Join(messageParts, ": "), true
|
||||
default:
|
||||
return "unknown", joinedMessage, true
|
||||
return "unknown", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,33 +375,7 @@ func isExtensionHealthAuthRequired(detail string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func isTransientHealthStatusMessage(text string) bool {
|
||||
t := strings.ToLower(strings.TrimSpace(text))
|
||||
if t == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(t, "context deadline exceeded") ||
|
||||
strings.Contains(t, "deadline exceeded") ||
|
||||
strings.Contains(t, "timeout") ||
|
||||
strings.Contains(t, "timed out") ||
|
||||
strings.Contains(t, "temporarily unavailable") ||
|
||||
strings.Contains(t, "try again")
|
||||
}
|
||||
|
||||
func isTransientHealthStatusCode(code int) bool {
|
||||
switch code {
|
||||
case http.StatusRequestTimeout,
|
||||
http.StatusTooManyRequests,
|
||||
http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable,
|
||||
http.StatusGatewayTimeout:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func healthNumber(value any) (int, bool) {
|
||||
func healthNumber(value interface{}) (int, bool) {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
return int(v), true
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -20,7 +18,7 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) {
|
||||
if status, msg := classifyExtensionHealthBody([]byte(`{"services":{"tidal":{"status":401,"label":"Tidal","detail":"auth_required"}}}`), "tidal"); status != "degraded" || !strings.Contains(msg, "Tidal") {
|
||||
t.Fatalf("service status/message = %q/%q", status, msg)
|
||||
}
|
||||
if status, msg, ok := classifyExtensionHealthService(map[string]any{"services": map[string]any{}}, "missing"); !ok || status != "unknown" || !strings.Contains(msg, "missing") {
|
||||
if status, msg, ok := classifyExtensionHealthService(map[string]interface{}{"services": map[string]interface{}{}}, "missing"); !ok || status != "unknown" || !strings.Contains(msg, "missing") {
|
||||
t.Fatalf("missing service = %q/%q/%v", status, msg, ok)
|
||||
}
|
||||
if n, ok := healthNumber(json.Number("503")); !ok || n != 503 {
|
||||
@@ -29,12 +27,6 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) {
|
||||
if !isExtensionHealthAuthRequired(" unauthorized ") {
|
||||
t.Fatal("expected auth required")
|
||||
}
|
||||
if !isTransientExtensionHealthError(context.DeadlineExceeded) || !isTransientExtensionHealthError(&net.DNSError{IsTimeout: true}) {
|
||||
t.Fatal("expected timeout health errors to be transient")
|
||||
}
|
||||
if !isTransientExtensionHealthError(&net.DNSError{IsNotFound: true}) {
|
||||
t.Fatal("expected health transport lookup errors to be indeterminate")
|
||||
}
|
||||
|
||||
if result := CheckExtensionHealth(nil); result.Status != "offline" {
|
||||
t.Fatalf("nil health = %#v", result)
|
||||
@@ -71,7 +63,11 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoverAndIDHSHelpers(t *testing.T) {
|
||||
func TestCoverRomajiParallelAndIDHSHelpers(t *testing.T) {
|
||||
spotify := "https://i.scdn.co/image/ab67616d00001e02abcdef"
|
||||
if got := GetCoverFromSpotify(spotify, true); !strings.Contains(got, spotifySizeMax) {
|
||||
t.Fatalf("spotify cover = %q", got)
|
||||
}
|
||||
if got := upgradeToMaxQuality("https://cdn-images.dzcdn.net/images/cover/abc/500x500-000000-80-0-0.jpg"); !strings.Contains(got, "1800x1800") {
|
||||
t.Fatalf("deezer cover = %q", got)
|
||||
}
|
||||
@@ -85,6 +81,32 @@ func TestCoverAndIDHSHelpers(t *testing.T) {
|
||||
t.Fatalf("expected empty cover error")
|
||||
}
|
||||
|
||||
if !ContainsJapanese("カタカナ") || ContainsJapanese("abc") {
|
||||
t.Fatal("unexpected Japanese detection")
|
||||
}
|
||||
if got := JapaneseToRomaji("きゃット"); got != "kyatto" {
|
||||
t.Fatalf("romaji = %q", got)
|
||||
}
|
||||
if got := BuildSearchQuery("きゃ! song", "アーティスト"); got != "atisuto kya song" {
|
||||
t.Fatalf("query = %q", got)
|
||||
}
|
||||
if got := CleanToASCII("A, B. C!"); got != "A B C" {
|
||||
t.Fatalf("ascii = %q", got)
|
||||
}
|
||||
|
||||
if err := PreWarmCache(`not-json`); err == nil {
|
||||
t.Fatal("expected prewarm JSON error")
|
||||
}
|
||||
if err := PreWarmCache(`[{"isrc":"ISRC","track_name":"Song","artist_name":"Artist","spotify_id":"sp","service":"tidal"}]`); err != nil {
|
||||
t.Fatalf("PreWarmCache: %v", err)
|
||||
}
|
||||
if result := FetchCoverAndLyricsParallel("", false, "", "", "", false, 0); result == nil || result.CoverErr != nil || result.LyricsErr != nil {
|
||||
t.Fatalf("parallel result = %#v", result)
|
||||
}
|
||||
if ClearTrackCache(); GetCacheSize() != 0 {
|
||||
t.Fatal("expected empty cache size")
|
||||
}
|
||||
|
||||
client := &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method != http.MethodPost {
|
||||
t.Fatalf("method = %s", req.Method)
|
||||
|
||||
+160
-408
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -45,110 +44,27 @@ func compareVersions(v1, v2 string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func isExtensionPackagePath(filePath string) bool {
|
||||
lowerPath := strings.ToLower(filePath)
|
||||
return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx")
|
||||
}
|
||||
|
||||
func managedExtensionPath(root, extensionID string) (string, error) {
|
||||
if root == "" {
|
||||
return "", fmt.Errorf("extension directory is not configured")
|
||||
}
|
||||
if !extensionIDPattern.MatchString(extensionID) {
|
||||
return "", fmt.Errorf("invalid extension ID %q", extensionID)
|
||||
}
|
||||
fullPath := filepath.Join(root, extensionID)
|
||||
if !isPathWithinBase(root, fullPath) {
|
||||
return "", fmt.Errorf("extension path escapes its managed directory")
|
||||
}
|
||||
return fullPath, nil
|
||||
}
|
||||
|
||||
func safeExtensionAssetPath(root, assetPath string) (string, bool) {
|
||||
if root == "" || assetPath == "" || filepath.IsAbs(assetPath) || strings.Contains(assetPath, `\`) {
|
||||
return "", false
|
||||
}
|
||||
cleaned := path.Clean(assetPath)
|
||||
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
|
||||
return "", false
|
||||
}
|
||||
fullPath := filepath.Join(root, filepath.FromSlash(cleaned))
|
||||
return fullPath, isPathWithinBase(root, fullPath)
|
||||
}
|
||||
|
||||
func extractExtensionArchive(zipReader *zip.ReadCloser, destination string) error {
|
||||
for _, file := range zipReader.File {
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
if file.FileInfo().Mode()&os.ModeSymlink != 0 || strings.Contains(file.Name, `\`) {
|
||||
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
|
||||
}
|
||||
|
||||
relPath := path.Clean(file.Name)
|
||||
if relPath == "." || relPath == ".." || strings.HasPrefix(relPath, "../") || path.IsAbs(relPath) {
|
||||
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
|
||||
}
|
||||
destPath := filepath.Join(destination, filepath.FromSlash(relPath))
|
||||
if !isPathWithinBase(destination, destPath) {
|
||||
return fmt.Errorf("unsafe path in extension archive: %s", file.Name)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create extension directory: %w", err)
|
||||
}
|
||||
destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create extension file: %w", err)
|
||||
}
|
||||
srcFile, err := file.Open()
|
||||
if err != nil {
|
||||
destFile.Close()
|
||||
return fmt.Errorf("failed to open file in archive: %w", err)
|
||||
}
|
||||
_, copyErr := io.Copy(destFile, srcFile)
|
||||
closeSrcErr := srcFile.Close()
|
||||
closeDestErr := destFile.Close()
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("failed to extract extension file: %w", copyErr)
|
||||
}
|
||||
if closeSrcErr != nil || closeDestErr != nil {
|
||||
return fmt.Errorf("failed to close extracted extension file")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type loadedExtension struct {
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
runtime *extensionRuntime
|
||||
indexProgram *goja.Program
|
||||
initialized bool
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
SourceDir string `json:"source_dir"`
|
||||
IconPath string `json:"icon_path"`
|
||||
|
||||
isolatedPoolMu sync.Mutex
|
||||
isolatedPool []*isolatedRuntimeHandle
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
runtime *extensionRuntime
|
||||
initialized bool
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
SourceDir string `json:"source_dir"`
|
||||
IconPath string `json:"icon_path"`
|
||||
}
|
||||
|
||||
type isolatedRuntimeHandle struct {
|
||||
vm *goja.Runtime
|
||||
runtime *extensionRuntime
|
||||
}
|
||||
|
||||
func getExtensionInitSettings(extensionID string) map[string]any {
|
||||
func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
||||
settings := GetExtensionSettingsStore().GetAll(extensionID)
|
||||
if len(settings) == 0 {
|
||||
return settings
|
||||
}
|
||||
|
||||
filtered := make(map[string]any, len(settings))
|
||||
filtered := make(map[string]interface{}, len(settings))
|
||||
for key, value := range settings {
|
||||
if strings.HasPrefix(key, "_") {
|
||||
continue
|
||||
@@ -250,8 +166,8 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
||||
}
|
||||
|
||||
func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loadedExtension, error) {
|
||||
if !isExtensionPackagePath(filePath) {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
||||
}
|
||||
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
@@ -321,33 +237,48 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
|
||||
return nil, fmt.Errorf("extension '%s' was installed by another process", manifest.DisplayName)
|
||||
}
|
||||
|
||||
extDir, err := managedExtensionPath(m.extensionsDir, manifest.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := os.Lstat(extDir); err == nil {
|
||||
return nil, fmt.Errorf("extension directory already exists for %q", manifest.Name)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("failed to inspect extension directory: %w", err)
|
||||
}
|
||||
stagingDir, err := os.MkdirTemp(m.extensionsDir, "."+manifest.Name+"-install-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create extension staging directory: %w", err)
|
||||
}
|
||||
stagingCommitted := false
|
||||
defer func() {
|
||||
if !stagingCommitted {
|
||||
_ = os.RemoveAll(stagingDir)
|
||||
}
|
||||
}()
|
||||
if err := extractExtensionArchive(zipReader, stagingDir); err != nil {
|
||||
return nil, err
|
||||
extDir := filepath.Join(m.extensionsDir, manifest.Name)
|
||||
if err := os.MkdirAll(extDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
||||
}
|
||||
|
||||
extDataDir, err := managedExtensionPath(m.dataDir, manifest.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
for _, file := range zipReader.File {
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
relPath := filepath.Clean(file.Name)
|
||||
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
|
||||
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
|
||||
continue
|
||||
}
|
||||
destPath := filepath.Join(extDir, relPath)
|
||||
|
||||
destDir := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
||||
}
|
||||
|
||||
destFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
||||
}
|
||||
|
||||
srcFile, err := file.Open()
|
||||
if err != nil {
|
||||
destFile.Close()
|
||||
return nil, fmt.Errorf("failed to open file in archive: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(destFile, srcFile)
|
||||
srcFile.Close()
|
||||
destFile.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
||||
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||
}
|
||||
@@ -357,7 +288,7 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
|
||||
Manifest: manifest,
|
||||
Enabled: false, // New extensions start disabled
|
||||
DataDir: extDataDir,
|
||||
SourceDir: stagingDir,
|
||||
SourceDir: extDir,
|
||||
}
|
||||
|
||||
if err := validateExtensionLoad(ext); err != nil {
|
||||
@@ -365,11 +296,6 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
|
||||
}
|
||||
if err := os.Rename(stagingDir, extDir); err != nil {
|
||||
return nil, fmt.Errorf("failed to activate extension: %w", err)
|
||||
}
|
||||
stagingCommitted = true
|
||||
ext.SourceDir = extDir
|
||||
|
||||
m.extensions[manifest.Name] = ext
|
||||
GoLog("[Extension] Loaded extension: %s v%s\n", manifest.DisplayName, manifest.Version)
|
||||
@@ -380,7 +306,6 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
|
||||
func initializeVMLocked(ext *loadedExtension) error {
|
||||
ext.VM = nil
|
||||
ext.runtime = nil
|
||||
ext.indexProgram = nil
|
||||
ext.initialized = false
|
||||
vm := goja.New()
|
||||
ext.VM = vm
|
||||
@@ -390,11 +315,6 @@ func initializeVMLocked(ext *loadedExtension) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read index.js: %w", err)
|
||||
}
|
||||
indexProgram, err := goja.Compile(indexPath, string(jsCode), false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compile extension code: %w", err)
|
||||
}
|
||||
ext.indexProgram = indexProgram
|
||||
|
||||
runtime := newExtensionRuntime(ext)
|
||||
ext.runtime = runtime
|
||||
@@ -403,7 +323,7 @@ func initializeVMLocked(ext *loadedExtension) error {
|
||||
|
||||
console := vm.NewObject()
|
||||
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
||||
args := make([]any, len(call.Arguments))
|
||||
args := make([]interface{}, len(call.Arguments))
|
||||
for i, arg := range call.Arguments {
|
||||
args[i] = arg.Export()
|
||||
}
|
||||
@@ -421,7 +341,7 @@ func initializeVMLocked(ext *loadedExtension) error {
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
_, err = vm.RunProgram(indexProgram)
|
||||
_, err = vm.RunString(string(jsCode))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute extension code: %w", err)
|
||||
}
|
||||
@@ -436,26 +356,20 @@ func initializeVMLocked(ext *loadedExtension) error {
|
||||
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
|
||||
vm := goja.New()
|
||||
|
||||
indexProgram := ext.indexProgram
|
||||
if indexProgram == nil {
|
||||
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
||||
jsCode, err := os.ReadFile(indexPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
|
||||
}
|
||||
indexProgram, err = goja.Compile(indexPath, string(jsCode), false)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to compile extension code: %w", err)
|
||||
}
|
||||
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
||||
jsCode, err := os.ReadFile(indexPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
|
||||
}
|
||||
|
||||
runtime := &extensionRuntime{
|
||||
extensionID: ext.ID,
|
||||
manifest: ext.Manifest,
|
||||
settings: make(map[string]any),
|
||||
cookieJar: nil,
|
||||
dataDir: ext.DataDir,
|
||||
vm: vm,
|
||||
extensionID: ext.ID,
|
||||
manifest: ext.Manifest,
|
||||
settings: make(map[string]interface{}),
|
||||
cookieJar: nil,
|
||||
dataDir: ext.DataDir,
|
||||
vm: vm,
|
||||
storageFlushDelay: defaultStorageFlushDelay,
|
||||
}
|
||||
if ext.runtime != nil && ext.runtime.cookieJar != nil {
|
||||
runtime.cookieJar = ext.runtime.cookieJar
|
||||
@@ -470,7 +384,7 @@ func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensio
|
||||
|
||||
console := vm.NewObject()
|
||||
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
||||
args := make([]any, len(call.Arguments))
|
||||
args := make([]interface{}, len(call.Arguments))
|
||||
for i, arg := range call.Arguments {
|
||||
args[i] = arg.Export()
|
||||
}
|
||||
@@ -488,7 +402,7 @@ func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensio
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
if _, err := vm.RunProgram(indexProgram); err != nil {
|
||||
if _, err := vm.RunString(string(jsCode)); err != nil {
|
||||
runtime.closeStorageFlusher()
|
||||
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
|
||||
}
|
||||
@@ -509,109 +423,6 @@ func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensio
|
||||
return vm, runtime, nil
|
||||
}
|
||||
|
||||
// A goja runtime plus an executed extension program is several MB of live
|
||||
// heap; rebuilding one per download multiplies that by the number of tracks.
|
||||
// Extensions already serve many calls on the persistent shared VM, so reusing
|
||||
// an initialized isolated runtime for consecutive downloads is the same
|
||||
// lifecycle contract.
|
||||
const maxIdleIsolatedRuntimes = 1
|
||||
|
||||
// acquireIsolatedExtensionRuntime pops an idle pooled runtime or builds one.
|
||||
func acquireIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
|
||||
ext.isolatedPoolMu.Lock()
|
||||
if n := len(ext.isolatedPool); n > 0 {
|
||||
handle := ext.isolatedPool[n-1]
|
||||
ext.isolatedPool = ext.isolatedPool[:n-1]
|
||||
ext.isolatedPoolMu.Unlock()
|
||||
return handle.vm, handle.runtime, nil
|
||||
}
|
||||
ext.isolatedPoolMu.Unlock()
|
||||
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
return newIsolatedExtensionRuntime(ext)
|
||||
}
|
||||
|
||||
// releaseIsolatedExtensionRuntime pools a healthy runtime for reuse or tears
|
||||
// it down. Pass healthy=false after an interrupt/timeout/script error, whose
|
||||
// VM state can't be trusted for reuse.
|
||||
func releaseIsolatedExtensionRuntime(ext *loadedExtension, vm *goja.Runtime, runtime *extensionRuntime, healthy, cleanupSafe bool) {
|
||||
if runtime != nil {
|
||||
if err := runtime.flushStorageNow(); err != nil {
|
||||
GoLog("[Extension:%s] isolated download storage flush failed: %v\n", ext.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if healthy && vm != nil && runtime != nil && ext.Enabled {
|
||||
ext.isolatedPoolMu.Lock()
|
||||
if len(ext.isolatedPool) < maxIdleIsolatedRuntimes {
|
||||
ext.isolatedPool = append(ext.isolatedPool, &isolatedRuntimeHandle{vm: vm, runtime: runtime})
|
||||
ext.isolatedPoolMu.Unlock()
|
||||
return
|
||||
}
|
||||
ext.isolatedPoolMu.Unlock()
|
||||
}
|
||||
|
||||
if cleanupSafe {
|
||||
if cleanupErr := runCleanupOnVM(vm); cleanupErr != nil {
|
||||
GoLog("[Extension:%s] isolated download cleanup failed: %v\n", ext.ID, cleanupErr)
|
||||
}
|
||||
}
|
||||
if runtime != nil {
|
||||
runtime.closeStorageFlusher()
|
||||
}
|
||||
}
|
||||
|
||||
// quarantineRuntimeLocked detaches a VM that remained busy after interrupt.
|
||||
// The caller holds VMMu. Touching or cleaning up that VM would race its stuck
|
||||
// goroutine; a later call will build a fresh runtime from indexProgram.
|
||||
func quarantineRuntimeLocked(ext *loadedExtension, vm *goja.Runtime) {
|
||||
if ext == nil || ext.VM != vm {
|
||||
return
|
||||
}
|
||||
ext.VM = nil
|
||||
ext.runtime = nil
|
||||
ext.initialized = false
|
||||
ext.Error = "extension runtime was quarantined after an unresponsive script"
|
||||
}
|
||||
|
||||
// drainIsolatedRuntimePool tears down idle isolated runtimes. Called on
|
||||
// extension teardown and on app-wide memory release.
|
||||
func drainIsolatedRuntimePool(ext *loadedExtension) {
|
||||
ext.isolatedPoolMu.Lock()
|
||||
pool := ext.isolatedPool
|
||||
ext.isolatedPool = nil
|
||||
ext.isolatedPoolMu.Unlock()
|
||||
|
||||
for _, handle := range pool {
|
||||
if cleanupErr := runCleanupOnVM(handle.vm); cleanupErr != nil {
|
||||
GoLog("[Extension:%s] isolated pool cleanup failed: %v\n", ext.ID, cleanupErr)
|
||||
}
|
||||
if handle.runtime != nil {
|
||||
if err := handle.runtime.flushStorageNow(); err != nil {
|
||||
GoLog("[Extension:%s] isolated pool storage flush failed: %v\n", ext.ID, err)
|
||||
}
|
||||
handle.runtime.closeStorageFlusher()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drainAllIsolatedRuntimePools releases every extension's idle isolated
|
||||
// runtimes (memory-pressure hook).
|
||||
func drainAllIsolatedRuntimePools() {
|
||||
m := getExtensionManager()
|
||||
m.mu.RLock()
|
||||
exts := make([]*loadedExtension, 0, len(m.extensions))
|
||||
for _, ext := range m.extensions {
|
||||
exts = append(exts, ext)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
for _, ext := range exts {
|
||||
drainIsolatedRuntimePool(ext)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
@@ -621,7 +432,7 @@ func (m *extensionManager) initializeVM(ext *loadedExtension) error {
|
||||
func initializeExtensionRuntimeWithSettings(
|
||||
vm *goja.Runtime,
|
||||
extensionID string,
|
||||
settings map[string]any,
|
||||
settings map[string]interface{},
|
||||
) error {
|
||||
settingsJSON, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
@@ -651,7 +462,7 @@ func initializeExtensionRuntimeWithSettings(
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]any); ok {
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
@@ -668,7 +479,7 @@ func initializeExtensionRuntimeWithSettings(
|
||||
|
||||
func initializeExtensionWithSettingsLocked(
|
||||
ext *loadedExtension,
|
||||
settings map[string]any,
|
||||
settings map[string]interface{},
|
||||
) error {
|
||||
if ext.VM == nil {
|
||||
return fmt.Errorf("extension failed to load: please reinstall the extension")
|
||||
@@ -723,7 +534,7 @@ func runCleanupOnVM(vm *goja.Runtime) error {
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]any); ok {
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
@@ -738,7 +549,6 @@ func runCleanupOnVM(vm *goja.Runtime) error {
|
||||
}
|
||||
|
||||
func teardownVMLocked(ext *loadedExtension) {
|
||||
drainIsolatedRuntimePool(ext)
|
||||
if err := runCleanupLocked(ext); err != nil {
|
||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
||||
}
|
||||
@@ -764,16 +574,6 @@ func validateExtensionLoad(ext *loadedExtension) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func teardownExtension(ext *loadedExtension) {
|
||||
if ext == nil {
|
||||
return
|
||||
}
|
||||
ext.Enabled = false
|
||||
ext.VMMu.Lock()
|
||||
teardownVMLocked(ext)
|
||||
ext.VMMu.Unlock()
|
||||
}
|
||||
|
||||
func (m *extensionManager) UnloadExtension(extensionID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -783,7 +583,6 @@ func (m *extensionManager) UnloadExtension(extensionID string) error {
|
||||
return fmt.Errorf("extension not found")
|
||||
}
|
||||
|
||||
ext.Enabled = false
|
||||
ext.VMMu.Lock()
|
||||
teardownVMLocked(ext)
|
||||
ext.VMMu.Unlock()
|
||||
@@ -874,7 +673,7 @@ func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
||||
loaded = append(loaded, ext.ID)
|
||||
}
|
||||
}
|
||||
} else if isExtensionPackagePath(entry.Name()) {
|
||||
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
|
||||
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
|
||||
if err != nil {
|
||||
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
||||
@@ -913,14 +712,7 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
expectedSourceDir, err := managedExtensionPath(m.extensionsDir, manifest.Name)
|
||||
if err != nil || filepath.Clean(dirPath) != filepath.Clean(expectedSourceDir) {
|
||||
return nil, fmt.Errorf("extension directory name must match manifest name %q", manifest.Name)
|
||||
}
|
||||
extDataDir, err := managedExtensionPath(m.dataDir, manifest.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
||||
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||
}
|
||||
@@ -962,27 +754,14 @@ func (m *extensionManager) RemoveExtension(extensionID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
sourceDir, err := managedExtensionPath(m.extensionsDir, ext.ID)
|
||||
if err != nil || !isPathWithinBase(m.extensionsDir, ext.SourceDir) || filepath.Clean(ext.SourceDir) != filepath.Clean(sourceDir) {
|
||||
return fmt.Errorf("refusing to remove extension outside the managed source directory")
|
||||
}
|
||||
dataDir, err := managedExtensionPath(m.dataDir, ext.ID)
|
||||
if err != nil || !isPathWithinBase(m.dataDir, ext.DataDir) || filepath.Clean(ext.DataDir) != filepath.Clean(dataDir) {
|
||||
return fmt.Errorf("refusing to remove extension outside the managed data directory")
|
||||
}
|
||||
|
||||
if err := m.UnloadExtension(extensionID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(sourceDir); err != nil {
|
||||
GoLog("[Extension] Warning: failed to remove source dir: %v\n", err)
|
||||
}
|
||||
|
||||
// Uninstall means gone: storage.json and encrypted credentials must not
|
||||
// linger on disk after the extension is removed.
|
||||
if err := os.RemoveAll(dataDir); err != nil {
|
||||
GoLog("[Extension] Warning: failed to remove data dir: %v\n", err)
|
||||
if ext.SourceDir != "" {
|
||||
if err := os.RemoveAll(ext.SourceDir); err != nil {
|
||||
GoLog("[Extension] Warning: failed to remove source dir: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -996,8 +775,8 @@ func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension,
|
||||
}
|
||||
|
||||
func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExtension, error) {
|
||||
if !isExtensionPackagePath(filePath) {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
||||
}
|
||||
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
@@ -1057,28 +836,56 @@ func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExten
|
||||
|
||||
GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version)
|
||||
|
||||
extDataDir, err := managedExtensionPath(m.dataDir, newManifest.Name)
|
||||
if err != nil || filepath.Clean(existing.DataDir) != filepath.Clean(extDataDir) {
|
||||
return nil, fmt.Errorf("installed extension has an invalid data directory")
|
||||
}
|
||||
extDir, err := managedExtensionPath(m.extensionsDir, newManifest.Name)
|
||||
if err != nil || filepath.Clean(existing.SourceDir) != filepath.Clean(extDir) {
|
||||
return nil, fmt.Errorf("installed extension has an invalid source directory")
|
||||
}
|
||||
extDataDir := existing.DataDir
|
||||
extDir := existing.SourceDir
|
||||
wasEnabled := existing.Enabled
|
||||
|
||||
stagingDir, err := os.MkdirTemp(m.extensionsDir, "."+newManifest.Name+"-upgrade-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create upgrade staging directory: %w", err)
|
||||
m.UnloadExtension(existing.ID)
|
||||
|
||||
if extDir != "" {
|
||||
if err := os.RemoveAll(extDir); err != nil {
|
||||
GoLog("[Extension] Warning: failed to remove old source dir: %v\n", err)
|
||||
}
|
||||
}
|
||||
stagingActive := true
|
||||
defer func() {
|
||||
if stagingActive {
|
||||
_ = os.RemoveAll(stagingDir)
|
||||
|
||||
if err := os.MkdirAll(extDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
||||
}
|
||||
|
||||
for _, file := range zipReader.File {
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
relPath := filepath.Clean(file.Name)
|
||||
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
|
||||
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
|
||||
continue
|
||||
}
|
||||
destPath := filepath.Join(extDir, relPath)
|
||||
|
||||
destDir := filepath.Dir(destPath)
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
||||
}
|
||||
|
||||
destFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
||||
}
|
||||
|
||||
srcFile, err := file.Open()
|
||||
if err != nil {
|
||||
destFile.Close()
|
||||
return nil, fmt.Errorf("failed to open file in archive: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(destFile, srcFile)
|
||||
srcFile.Close()
|
||||
destFile.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract file: %w", err)
|
||||
}
|
||||
}()
|
||||
if err := extractExtensionArchive(zipReader, stagingDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ext := &loadedExtension{
|
||||
@@ -1086,53 +893,22 @@ func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExten
|
||||
Manifest: newManifest,
|
||||
Enabled: wasEnabled, // Preserve enabled state from before upgrade
|
||||
DataDir: extDataDir,
|
||||
SourceDir: stagingDir,
|
||||
SourceDir: extDir,
|
||||
}
|
||||
|
||||
if wasEnabled {
|
||||
if err := ext.ensureRuntimeReady(); err != nil {
|
||||
return nil, fmt.Errorf("upgraded extension failed validation: %w", err)
|
||||
GoLog("[Extension] Failed to initialize upgraded extension %s: %v\n", newManifest.Name, err)
|
||||
}
|
||||
} else if err := validateExtensionLoad(ext); err != nil {
|
||||
return nil, fmt.Errorf("upgraded extension failed validation: %w", err)
|
||||
}
|
||||
|
||||
backupDir, err := os.MkdirTemp(m.extensionsDir, "."+newManifest.Name+"-backup-*")
|
||||
if err != nil {
|
||||
teardownExtension(ext)
|
||||
return nil, fmt.Errorf("failed to prepare upgrade backup: %w", err)
|
||||
}
|
||||
if err := os.Remove(backupDir); err != nil {
|
||||
teardownExtension(ext)
|
||||
return nil, fmt.Errorf("failed to prepare upgrade backup: %w", err)
|
||||
}
|
||||
if err := os.Rename(extDir, backupDir); err != nil {
|
||||
teardownExtension(ext)
|
||||
return nil, fmt.Errorf("failed to preserve current extension: %w", err)
|
||||
}
|
||||
if err := os.Rename(stagingDir, extDir); err != nil {
|
||||
_ = os.Rename(backupDir, extDir)
|
||||
teardownExtension(ext)
|
||||
return nil, fmt.Errorf("failed to activate upgraded extension: %w", err)
|
||||
}
|
||||
stagingActive = false
|
||||
ext.SourceDir = extDir
|
||||
|
||||
existing.Enabled = false
|
||||
if err := m.UnloadExtension(existing.ID); err != nil {
|
||||
_ = os.RemoveAll(extDir)
|
||||
_ = os.Rename(backupDir, extDir)
|
||||
existing.Enabled = wasEnabled
|
||||
teardownExtension(ext)
|
||||
return nil, fmt.Errorf("failed to unload current extension: %w", err)
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Failed to validate upgraded extension %s: %v\n", newManifest.Name, err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.extensions[newManifest.Name] = ext
|
||||
m.mu.Unlock()
|
||||
if err := os.RemoveAll(backupDir); err != nil {
|
||||
GoLog("[Extension] Warning: failed to remove upgrade backup: %v\n", err)
|
||||
}
|
||||
|
||||
GoLog("[Extension] Upgraded extension: %s to v%s\n", newManifest.DisplayName, newManifest.Version)
|
||||
|
||||
@@ -1148,8 +924,8 @@ type ExtensionUpgradeInfo struct {
|
||||
}
|
||||
|
||||
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||
if !isExtensionPackagePath(filePath) {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
||||
}
|
||||
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
@@ -1248,7 +1024,7 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
||||
ServiceHealth []ExtensionHealthCheck `json:"service_health,omitempty"`
|
||||
Capabilities map[string]any `json:"capabilities,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
infos := make([]ExtensionInfo, len(extensions))
|
||||
@@ -1260,15 +1036,6 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
if ext.Manifest.Permissions.Storage {
|
||||
permissions = append(permissions, "storage:enabled")
|
||||
}
|
||||
if ext.Manifest.Permissions.File {
|
||||
permissions = append(permissions, "file:enabled")
|
||||
}
|
||||
if ext.Manifest.Permissions.AllowHTTP {
|
||||
permissions = append(permissions, "network:http")
|
||||
}
|
||||
if ext.Manifest.HasCapability("rawFfmpeg") {
|
||||
permissions = append(permissions, "ffmpeg:raw")
|
||||
}
|
||||
|
||||
status := "loaded"
|
||||
if ext.Error != "" {
|
||||
@@ -1279,19 +1046,15 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
|
||||
iconPath := ""
|
||||
if ext.Manifest.Icon != "" && ext.SourceDir != "" {
|
||||
possibleIcon, safe := safeExtensionAssetPath(ext.SourceDir, ext.Manifest.Icon)
|
||||
if safe {
|
||||
if _, err := os.Stat(possibleIcon); err == nil {
|
||||
iconPath = possibleIcon
|
||||
}
|
||||
possibleIcon := filepath.Join(ext.SourceDir, ext.Manifest.Icon)
|
||||
if _, err := os.Stat(possibleIcon); err == nil {
|
||||
iconPath = possibleIcon
|
||||
}
|
||||
}
|
||||
if iconPath == "" && ext.SourceDir != "" {
|
||||
possibleIcon, safe := safeExtensionAssetPath(ext.SourceDir, "icon.png")
|
||||
if safe {
|
||||
if _, err := os.Stat(possibleIcon); err == nil {
|
||||
iconPath = possibleIcon
|
||||
}
|
||||
possibleIcon := filepath.Join(ext.SourceDir, "icon.png")
|
||||
if _, err := os.Stat(possibleIcon); err == nil {
|
||||
iconPath = possibleIcon
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1332,7 +1095,7 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func (m *extensionManager) InitializeExtension(extensionID string, settings map[string]any) error {
|
||||
func (m *extensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -1387,7 +1150,7 @@ func (m *extensionManager) UnloadAllExtensions() {
|
||||
GoLog("[Extension] All extensions unloaded\n")
|
||||
}
|
||||
|
||||
func (m *extensionManager) InvokeAction(extensionID string, actionName string) (map[string]any, error) {
|
||||
func (m *extensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -1407,16 +1170,14 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
|
||||
|
||||
// Merge extension return values onto the top-level JSON object so Flutter can read
|
||||
// message, open_auth_url, setting_updates without unwrapping a nested "result" key.
|
||||
actionNameLiteral := strconv.Quote(actionName)
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
var actionName = %s;
|
||||
function runAction(fn) {
|
||||
try {
|
||||
var result = fn();
|
||||
if (result && typeof result.then === 'function') {
|
||||
return { success: true, pending: true, message: 'Action started' };
|
||||
}
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||
try {
|
||||
var result = extension.%s();
|
||||
if (result && typeof result.then === 'function') {
|
||||
return { success: true, pending: true, message: 'Action started' };
|
||||
}
|
||||
if (result !== null && result !== undefined && typeof result === 'object') {
|
||||
var isArr = false;
|
||||
if (typeof Array !== 'undefined' && Array.isArray) {
|
||||
@@ -1431,38 +1192,29 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
|
||||
}
|
||||
}
|
||||
return { success: true, result: result };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
if (typeof extension !== 'undefined' && extension && typeof extension[actionName] === 'function') {
|
||||
return runAction(function() { return extension[actionName](); });
|
||||
}
|
||||
if (actionName === 'completeGrant' && typeof session !== 'undefined' && session && typeof session.completeGrant === 'function') {
|
||||
return runAction(function() { return session.completeGrant(); });
|
||||
}
|
||||
return { success: false, error: 'Action function not found: ' + actionName };
|
||||
})()
|
||||
`, actionNameLiteral)
|
||||
}
|
||||
return { success: false, error: 'Action function not found: %s' };
|
||||
})()
|
||||
`, actionName, actionName, actionName)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
|
||||
if err != nil {
|
||||
if IsRuntimeUnsafeError(err) {
|
||||
quarantineRuntimeLocked(ext, vm)
|
||||
}
|
||||
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
|
||||
return nil, fmt.Errorf("action failed: %v", err)
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) {
|
||||
return map[string]any{"success": true}, nil
|
||||
return map[string]interface{}{"success": true}, nil
|
||||
}
|
||||
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]any); ok {
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
GoLog("[Extension] InvokeAction %s.%s result: %v\n", extensionID, actionName, resultMap)
|
||||
return resultMap, nil
|
||||
}
|
||||
|
||||
return map[string]any{"success": true, "result": exported}, nil
|
||||
return map[string]interface{}{"success": true, "result": exported}, nil
|
||||
}
|
||||
|
||||
@@ -34,13 +34,9 @@ registerExtension({
|
||||
});
|
||||
`
|
||||
pkgV1 := filepath.Join(dir, "manager-ext-v1.spotiflac-ext")
|
||||
createTestExtensionPackage(t, pkgV1, "manager-ext", "1.0.0", js, nil)
|
||||
createTestExtensionPackage(t, pkgV1, "manager-ext", "1.0.0", js, map[string]string{"../unsafe.txt": "skip"})
|
||||
pkgV2 := filepath.Join(dir, "manager-ext-v2.spotiflac-ext")
|
||||
createTestExtensionPackage(t, pkgV2, "manager-ext", "1.1.0", js, nil)
|
||||
brokenUpgrade := filepath.Join(dir, "manager-ext-broken.spotiflac-ext")
|
||||
createTestExtensionPackage(t, brokenUpgrade, "manager-ext", "1.0.1", `registerExtension({`, nil)
|
||||
unsafePkg := filepath.Join(dir, "unsafe.spotiflac-ext")
|
||||
createTestExtensionPackage(t, unsafePkg, "manager-ext", "1.0.0", js, map[string]string{"../unsafe.txt": "blocked"})
|
||||
|
||||
if compareVersions("v1.2.0", "1.1.9") <= 0 || compareVersions("1.0.0", "1.0") != 0 || compareVersions("1.0.0", "1.0.1") >= 0 {
|
||||
t.Fatal("compareVersions mismatch")
|
||||
@@ -51,9 +47,6 @@ registerExtension({
|
||||
if _, err := manager.LoadExtensionFromFile(filepath.Join(dir, "missing.spotiflac-ext")); err == nil {
|
||||
t.Fatal("expected invalid package error")
|
||||
}
|
||||
if _, err := manager.LoadExtensionFromFile(unsafePkg); err == nil {
|
||||
t.Fatal("expected unsafe archive path to reject the package")
|
||||
}
|
||||
|
||||
ext, err := manager.LoadExtensionFromFile(pkgV1)
|
||||
if err != nil {
|
||||
@@ -62,6 +55,9 @@ registerExtension({
|
||||
if ext.ID != "manager-ext" || ext.Enabled || ext.SourceDir == "" {
|
||||
t.Fatalf("loaded extension = %#v", ext)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(ext.SourceDir, "unsafe.txt")); err == nil {
|
||||
t.Fatal("unsafe archive path should not be extracted")
|
||||
}
|
||||
if _, err := manager.LoadExtensionFromFile(pkgV1); err == nil {
|
||||
t.Fatal("expected duplicate version error")
|
||||
}
|
||||
@@ -70,7 +66,7 @@ registerExtension({
|
||||
if err != nil || !strings.Contains(installedJSON, "manager-ext") || !strings.Contains(installedJSON, "icon_path") {
|
||||
t.Fatalf("GetInstalledExtensionsJSON = %q/%v", installedJSON, err)
|
||||
}
|
||||
var installed []map[string]any
|
||||
var installed []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(installedJSON), &installed); err != nil || len(installed) != 1 {
|
||||
t.Fatalf("decode installed = %#v/%v", installed, err)
|
||||
}
|
||||
@@ -84,7 +80,7 @@ registerExtension({
|
||||
if !ext.Enabled || ext.VM == nil || !ext.initialized {
|
||||
t.Fatalf("enabled extension = %#v", ext)
|
||||
}
|
||||
if err := manager.InitializeExtension("manager-ext", map[string]any{"quality": "hires"}); err != nil {
|
||||
if err := manager.InitializeExtension("manager-ext", map[string]interface{}{"quality": "hires"}); err != nil {
|
||||
t.Fatalf("InitializeExtension: %v", err)
|
||||
}
|
||||
action, err := manager.InvokeAction("manager-ext", "doAction")
|
||||
@@ -97,16 +93,6 @@ registerExtension({
|
||||
if err := manager.SetExtensionEnabled("manager-ext", false); err != nil {
|
||||
t.Fatalf("disable extension: %v", err)
|
||||
}
|
||||
if _, err := manager.UpgradeExtension(brokenUpgrade); err == nil {
|
||||
t.Fatal("expected invalid upgrade to be rejected")
|
||||
}
|
||||
stillInstalled, err := manager.GetExtension("manager-ext")
|
||||
if err != nil || stillInstalled.Manifest.Version != "1.0.0" {
|
||||
t.Fatalf("failed upgrade replaced the installed version: %#v/%v", stillInstalled, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(stillInstalled.SourceDir, "index.js")); err != nil {
|
||||
t.Fatalf("failed upgrade removed the working source: %v", err)
|
||||
}
|
||||
if ext.VM != nil || ext.initialized {
|
||||
t.Fatalf("expected VM teardown, got %#v", ext)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,9 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var extensionIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,127}$`)
|
||||
|
||||
type ExtensionType string
|
||||
|
||||
const (
|
||||
@@ -42,7 +38,7 @@ type ExtensionSetting struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
}
|
||||
@@ -61,7 +57,7 @@ type QualitySpecificSetting struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Secret bool `json:"secret,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
Default interface{} `json:"default,omitempty"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
@@ -117,49 +113,28 @@ type ExtensionHealthCheck struct {
|
||||
Required bool `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
type SignedSessionEndpoints struct {
|
||||
Bootstrap string `json:"bootstrap,omitempty"`
|
||||
Challenge string `json:"challenge,omitempty"`
|
||||
Exchange string `json:"exchange,omitempty"`
|
||||
Refresh string `json:"refresh,omitempty"`
|
||||
}
|
||||
|
||||
type SignedSessionConfig struct {
|
||||
Namespace string `json:"namespace"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
AppVersion string `json:"appVersion,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
CallbackURL string `json:"callbackUrl,omitempty"`
|
||||
SchemeLabel string `json:"schemeLabel,omitempty"`
|
||||
HeaderPrefix string `json:"headerPrefix,omitempty"`
|
||||
TimeWindowSeconds int `json:"timeWindowSeconds,omitempty"`
|
||||
Endpoints SignedSessionEndpoints `json:"endpoints,omitempty"`
|
||||
}
|
||||
|
||||
type ExtensionManifest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Types []ExtensionType `json:"type"`
|
||||
Permissions ExtensionPermissions `json:"permissions"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
||||
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
|
||||
SignedSession *SignedSessionConfig `json:"signedSession,omitempty"`
|
||||
RequiredRuntimeFeatures []string `json:"requiredRuntimeFeatures,omitempty"`
|
||||
Capabilities map[string]any `json:"capabilities,omitempty"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Types []ExtensionType `json:"type"`
|
||||
Permissions ExtensionPermissions `json:"permissions"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
||||
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestValidationError struct {
|
||||
@@ -188,12 +163,6 @@ func (m *ExtensionManifest) Validate() error {
|
||||
if strings.TrimSpace(m.Name) == "" {
|
||||
return &ManifestValidationError{Field: "name", Message: "name is required"}
|
||||
}
|
||||
if !extensionIDPattern.MatchString(m.Name) {
|
||||
return &ManifestValidationError{
|
||||
Field: "name",
|
||||
Message: "name must be a lowercase extension ID containing only letters, numbers, '.', '_' or '-'",
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.Version) == "" {
|
||||
return &ManifestValidationError{Field: "version", Message: "version is required"}
|
||||
@@ -231,6 +200,7 @@ func (m *ExtensionManifest) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Select type requires options
|
||||
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].options", i),
|
||||
@@ -268,47 +238,9 @@ func (m *ExtensionManifest) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if m.SignedSession != nil {
|
||||
if !m.Permissions.Storage {
|
||||
return &ManifestValidationError{Field: "permissions.storage", Message: "signedSession requires storage permission"}
|
||||
}
|
||||
if strings.TrimSpace(m.SignedSession.Namespace) == "" {
|
||||
return &ManifestValidationError{Field: "signedSession.namespace", Message: "namespace is required"}
|
||||
}
|
||||
baseURL := strings.TrimSpace(m.SignedSession.BaseURL)
|
||||
if baseURL == "" {
|
||||
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is required"}
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(baseURL), "https://") {
|
||||
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl must use https"}
|
||||
}
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is invalid"}
|
||||
}
|
||||
if !m.IsDomainAllowed(parsed.Hostname()) {
|
||||
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl host must be listed in permissions.network"}
|
||||
}
|
||||
}
|
||||
if m.HasCapability("rawFfmpeg") && !m.Permissions.File {
|
||||
return &ManifestValidationError{Field: "permissions.file", Message: "rawFfmpeg capability requires file permission"}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) HasCapability(name string) bool {
|
||||
if m == nil || m.Capabilities == nil {
|
||||
return false
|
||||
}
|
||||
value, ok := m.Capabilities[name]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
enabled, ok := value.(bool)
|
||||
return ok && enabled
|
||||
}
|
||||
|
||||
func (m *ExtensionManifest) HasType(t ExtensionType) bool {
|
||||
for _, et := range m.Types {
|
||||
if et == t {
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var providerPriority []string
|
||||
var providerPriorityMu sync.RWMutex
|
||||
|
||||
var extensionFallbackProviderIDs []string
|
||||
var extensionFallbackProviderIDsMu sync.RWMutex
|
||||
|
||||
var metadataProviderPriority []string
|
||||
var metadataProviderPriorityMu sync.RWMutex
|
||||
|
||||
func SetProviderPriority(providerIDs []string) {
|
||||
providerPriorityMu.Lock()
|
||||
defer providerPriorityMu.Unlock()
|
||||
providerPriority = sanitizeDownloadProviderPriority(providerIDs)
|
||||
GoLog("[Extension] Download provider priority set: %v\n", providerPriority)
|
||||
}
|
||||
|
||||
func GetProviderPriority() []string {
|
||||
providerPriorityMu.RLock()
|
||||
defer providerPriorityMu.RUnlock()
|
||||
|
||||
if len(providerPriority) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
result := make([]string, len(providerPriority))
|
||||
copy(result, providerPriority)
|
||||
return result
|
||||
}
|
||||
|
||||
func sanitizeDownloadProviderPriority(providerIDs []string) []string {
|
||||
sanitized := make([]string, 0, len(providerIDs))
|
||||
seen := map[string]struct{}{}
|
||||
|
||||
for _, providerID := range providerIDs {
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
if providerID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if isRetiredBuiltInDownloadProvider(providerID) {
|
||||
continue
|
||||
}
|
||||
|
||||
seenKey := strings.ToLower(providerID)
|
||||
if _, exists := seen[seenKey]; exists {
|
||||
continue
|
||||
}
|
||||
seen[seenKey] = struct{}{}
|
||||
sanitized = append(sanitized, providerID)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func isRetiredBuiltInDownloadProvider(providerID string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(providerID))
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
switch normalized {
|
||||
case "deezer", "qobuz", "tidal":
|
||||
return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool {
|
||||
return manifest.IsDownloadProvider()
|
||||
})
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isRetiredBuiltInMetadataProvider(providerID string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(providerID))
|
||||
if normalized == "" {
|
||||
return false
|
||||
}
|
||||
switch normalized {
|
||||
case "deezer", "spotify", "qobuz", "tidal":
|
||||
return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool {
|
||||
return manifest.IsMetadataProvider()
|
||||
})
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hasEnabledExtensionProvider(providerID string, matches func(*ExtensionManifest) bool) bool {
|
||||
if providerID == "" || matches == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
manager := getExtensionManager()
|
||||
manager.mu.RLock()
|
||||
defer manager.mu.RUnlock()
|
||||
|
||||
for id, ext := range manager.extensions {
|
||||
if !strings.EqualFold(strings.TrimSpace(id), providerID) {
|
||||
continue
|
||||
}
|
||||
if ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil {
|
||||
return false
|
||||
}
|
||||
return matches(ext.Manifest)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func SetExtensionFallbackProviderIDs(providerIDs []string) {
|
||||
extensionFallbackProviderIDsMu.Lock()
|
||||
defer extensionFallbackProviderIDsMu.Unlock()
|
||||
|
||||
if providerIDs == nil {
|
||||
extensionFallbackProviderIDs = nil
|
||||
GoLog("[Extension] Extension fallback providers reset to default (all enabled download extensions)\n")
|
||||
return
|
||||
}
|
||||
|
||||
sanitized := make([]string, 0, len(providerIDs))
|
||||
seen := map[string]struct{}{}
|
||||
for _, providerID := range providerIDs {
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
if providerID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[providerID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[providerID] = struct{}{}
|
||||
sanitized = append(sanitized, providerID)
|
||||
}
|
||||
|
||||
extensionFallbackProviderIDs = sanitized
|
||||
GoLog("[Extension] Extension fallback providers set: %v\n", sanitized)
|
||||
}
|
||||
|
||||
func GetExtensionFallbackProviderIDs() []string {
|
||||
extensionFallbackProviderIDsMu.RLock()
|
||||
defer extensionFallbackProviderIDsMu.RUnlock()
|
||||
|
||||
if extensionFallbackProviderIDs == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]string, len(extensionFallbackProviderIDs))
|
||||
copy(result, extensionFallbackProviderIDs)
|
||||
return result
|
||||
}
|
||||
|
||||
func isExtensionFallbackAllowed(providerID string) bool {
|
||||
allowed := GetExtensionFallbackProviderIDs()
|
||||
if allowed == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, allowedProviderID := range allowed {
|
||||
if allowedProviderID == providerID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func SetMetadataProviderPriority(providerIDs []string) {
|
||||
metadataProviderPriorityMu.Lock()
|
||||
defer metadataProviderPriorityMu.Unlock()
|
||||
|
||||
sanitized := make([]string, 0, len(providerIDs))
|
||||
seen := map[string]struct{}{}
|
||||
for _, providerID := range providerIDs {
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
if providerID == "" || isRetiredBuiltInMetadataProvider(providerID) {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[providerID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[providerID] = struct{}{}
|
||||
sanitized = append(sanitized, providerID)
|
||||
}
|
||||
metadataProviderPriority = sanitized
|
||||
GoLog("[Extension] Metadata provider priority set: %v\n", sanitized)
|
||||
}
|
||||
|
||||
func GetMetadataProviderPriority() []string {
|
||||
metadataProviderPriorityMu.RLock()
|
||||
defer metadataProviderPriorityMu.RUnlock()
|
||||
|
||||
if len(metadataProviderPriority) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
result := make([]string, len(metadataProviderPriority))
|
||||
copy(result, metadataProviderPriority)
|
||||
return result
|
||||
}
|
||||
@@ -57,14 +57,22 @@ func TestExtensionProviderWrapperFullSurface(t *testing.T) {
|
||||
t.Fatalf("enriched = %#v", enriched)
|
||||
}
|
||||
|
||||
availability, err := provider.CheckAvailabilityForItemID("ISRC", "Song", "Artist", "spotify:1", "dz", "tidal", "qobuz", 0, "")
|
||||
availability, err := provider.CheckAvailability("ISRC", "Song", "Artist", "spotify:1", "dz", "tidal", "qobuz")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckAvailabilityForItemID: %v", err)
|
||||
t.Fatalf("CheckAvailability: %v", err)
|
||||
}
|
||||
if !availability.Available || availability.TrackID != "download-track" || !availability.SkipFallback {
|
||||
t.Fatalf("availability = %#v", availability)
|
||||
}
|
||||
|
||||
downloadURL, err := provider.GetDownloadURL("track-1", "LOSSLESS")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDownloadURL: %v", err)
|
||||
}
|
||||
if downloadURL.Format != "flac" || downloadURL.BitDepth != 24 || downloadURL.SampleRate != 96000 {
|
||||
t.Fatalf("download URL = %#v", downloadURL)
|
||||
}
|
||||
|
||||
progress := []int{}
|
||||
download, err := provider.Download("track-1", "LOSSLESS", filepath.Join(t.TempDir(), "song.flac"), "", func(percent int) {
|
||||
progress = append(progress, percent)
|
||||
@@ -92,7 +100,18 @@ func TestExtensionProviderWrapperFullSurface(t *testing.T) {
|
||||
t.Fatalf("url result = %#v", urlResult)
|
||||
}
|
||||
|
||||
post, err := provider.PostProcess(filepath.Join(t.TempDir(), "song.flac"), map[string]any{"title": "Song"}, "hook")
|
||||
match, err := provider.MatchTrack(
|
||||
map[string]interface{}{"name": "Song", "artists": "Artist"},
|
||||
[]map[string]interface{}{{"id": "download-track", "name": "Song"}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("MatchTrack: %v", err)
|
||||
}
|
||||
if !match.Matched || match.TrackID != "download-track" {
|
||||
t.Fatalf("match = %#v", match)
|
||||
}
|
||||
|
||||
post, err := provider.PostProcess(filepath.Join(t.TempDir(), "song.flac"), map[string]interface{}{"title": "Song"}, "hook")
|
||||
if err != nil {
|
||||
t.Fatalf("PostProcess: %v", err)
|
||||
}
|
||||
@@ -102,8 +121,8 @@ func TestExtensionProviderWrapperFullSurface(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtensionProviderAndManagerSelectionHelpers(t *testing.T) {
|
||||
manifest := &ExtensionManifest{Capabilities: map[string]any{
|
||||
"replacesBuiltInProviders": []any{" Deezer ", 7, ""},
|
||||
manifest := &ExtensionManifest{Capabilities: map[string]interface{}{
|
||||
"replacesBuiltInProviders": []interface{}{" Deezer ", 7, ""},
|
||||
}}
|
||||
if values := manifestCapabilityStringList(manifest, "replacesBuiltInProviders"); len(values) != 1 || values[0] != "deezer" {
|
||||
t.Fatalf("capability list = %#v", values)
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import "strings"
|
||||
|
||||
type ExtTrackMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists string `json:"artists"`
|
||||
AlbumName string `json:"album_name"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
AlbumID string `json:"album_id,omitempty"`
|
||||
AlbumURL string `json:"album_url,omitempty"`
|
||||
ArtistID string `json:"artist_id,omitempty"`
|
||||
ArtistURL string `json:"artist_url,omitempty"`
|
||||
ExternalURL string `json:"external_urls,omitempty"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
Images string `json:"images,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ItemType string `json:"item_type,omitempty"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
Explicit bool `json:"explicit,omitempty"`
|
||||
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
ExternalLinks map[string]string `json:"external_links,omitempty"`
|
||||
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
|
||||
AudioQuality string `json:"audio_quality,omitempty"`
|
||||
AudioModes string `json:"audio_modes,omitempty"`
|
||||
}
|
||||
|
||||
func (t *ExtTrackMetadata) ResolvedCoverURL() string {
|
||||
if t.CoverURL != "" {
|
||||
return t.CoverURL
|
||||
}
|
||||
return t.Images
|
||||
}
|
||||
|
||||
type ExtAlbumMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Artists string `json:"artists"`
|
||||
ArtistID string `json:"artist_id,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
HeaderImage string `json:"header_image,omitempty"`
|
||||
HeaderVideo string `json:"header_video,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
AudioTraits []string `json:"audio_traits,omitempty"`
|
||||
Tracks []ExtTrackMetadata `json:"tracks"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
}
|
||||
|
||||
type ExtArtistMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
HeaderImage string `json:"header_image,omitempty"`
|
||||
HeaderVideo string `json:"header_video,omitempty"`
|
||||
Listeners int `json:"listeners,omitempty"`
|
||||
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
||||
Releases []ExtAlbumMetadata `json:"releases,omitempty"`
|
||||
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
}
|
||||
|
||||
type ExtSearchResult struct {
|
||||
Tracks []ExtTrackMetadata `json:"tracks"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type ExtAvailabilityResult struct {
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
TrackID string `json:"track_id,omitempty"`
|
||||
SkipFallback bool `json:"skip_fallback,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadDecryptionInfo struct {
|
||||
Strategy string `json:"strategy,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
IV string `json:"iv,omitempty"`
|
||||
InputFormat string `json:"input_format,omitempty"`
|
||||
OutputExtension string `json:"output_extension,omitempty"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type ExtDownloadResult struct {
|
||||
Success bool `json:"success"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
BitDepth int `json:"bit_depth,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
AudioCodec string `json:"audio_codec,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"`
|
||||
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
|
||||
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
LyricsLRC string `json:"lyrics_lrc,omitempty"`
|
||||
DecryptionKey string `json:"decryption_key,omitempty"`
|
||||
Decryption *DownloadDecryptionInfo `json:"decryption,omitempty"`
|
||||
ActualExtension string `json:"actual_extension,omitempty"`
|
||||
OutputExtension string `json:"output_extension,omitempty"`
|
||||
ActualContainer string `json:"actual_container,omitempty"`
|
||||
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
|
||||
}
|
||||
|
||||
const genericFFmpegMOVDecryptionStrategy = "ffmpeg.mov_key"
|
||||
|
||||
func cloneDownloadDecryptionInfo(info *DownloadDecryptionInfo) *DownloadDecryptionInfo {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := &DownloadDecryptionInfo{
|
||||
Strategy: strings.TrimSpace(info.Strategy),
|
||||
Key: strings.TrimSpace(info.Key),
|
||||
IV: strings.TrimSpace(info.IV),
|
||||
InputFormat: strings.TrimSpace(info.InputFormat),
|
||||
OutputExtension: strings.TrimSpace(info.OutputExtension),
|
||||
}
|
||||
if len(info.Options) > 0 {
|
||||
cloned.Options = make(map[string]any, len(info.Options))
|
||||
for key, value := range info.Options {
|
||||
cloned.Options[key] = value
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func normalizeDownloadDecryptionStrategy(strategy string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(strategy)) {
|
||||
case "", "ffmpeg.mov_key", "ffmpeg_mov_key", "mov_decryption_key", "mp4_decryption_key", "ffmpeg.mp4_decryption_key":
|
||||
return genericFFmpegMOVDecryptionStrategy
|
||||
default:
|
||||
return strings.TrimSpace(strategy)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeDownloadDecryptionInfo(info *DownloadDecryptionInfo, legacyKey string) *DownloadDecryptionInfo {
|
||||
normalized := cloneDownloadDecryptionInfo(info)
|
||||
trimmedLegacyKey := strings.TrimSpace(legacyKey)
|
||||
|
||||
if normalized == nil {
|
||||
if trimmedLegacyKey == "" {
|
||||
return nil
|
||||
}
|
||||
return &DownloadDecryptionInfo{
|
||||
Strategy: genericFFmpegMOVDecryptionStrategy,
|
||||
Key: trimmedLegacyKey,
|
||||
InputFormat: "mov",
|
||||
}
|
||||
}
|
||||
|
||||
normalized.Strategy = normalizeDownloadDecryptionStrategy(normalized.Strategy)
|
||||
if normalized.Key == "" && trimmedLegacyKey != "" {
|
||||
normalized.Key = trimmedLegacyKey
|
||||
}
|
||||
if normalized.Strategy == "" && normalized.Key != "" {
|
||||
normalized.Strategy = genericFFmpegMOVDecryptionStrategy
|
||||
}
|
||||
if normalized.Strategy == genericFFmpegMOVDecryptionStrategy && normalized.InputFormat == "" {
|
||||
normalized.InputFormat = "mov"
|
||||
}
|
||||
if normalized.Strategy == genericFFmpegMOVDecryptionStrategy && normalized.Key == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizedDownloadDecryptionKey(info *DownloadDecryptionInfo, legacyKey string) string {
|
||||
if normalized := normalizeDownloadDecryptionInfo(info, legacyKey); normalized != nil {
|
||||
if normalized.Strategy == genericFFmpegMOVDecryptionStrategy {
|
||||
return normalized.Key
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(legacyKey)
|
||||
}
|
||||
@@ -1,978 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type extensionProviderWrapper struct {
|
||||
extension *loadedExtension
|
||||
vm *goja.Runtime
|
||||
}
|
||||
|
||||
func newExtensionProviderWrapper(ext *loadedExtension) *extensionProviderWrapper {
|
||||
return &extensionProviderWrapper{
|
||||
extension: ext,
|
||||
vm: ext.VM,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) lockReadyVM() error {
|
||||
vm, err := p.extension.lockReadyVM()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.vm = vm
|
||||
return nil
|
||||
}
|
||||
|
||||
// extCallOpts configures a shared extension invocation. It covers the
|
||||
// skeleton common to most extensionProviderWrapper methods: perf tracking, VM
|
||||
// locking, optional download/request cancellation binding, and translating
|
||||
// timeouts/cancellation into the right error.
|
||||
type extCallOpts struct {
|
||||
perfName string
|
||||
invoke func(vm *goja.Runtime) (goja.Value, error)
|
||||
timeout time.Duration
|
||||
itemID string // optional: binds download-cancel + active-item tracking
|
||||
requestID string // optional: binds request-cancel via context (customSearch only)
|
||||
// beforeRun runs after lock+cancel setup, right before the invocation. Its
|
||||
// returned cleanup, if any, runs after the call.
|
||||
beforeRun func() func()
|
||||
// timeoutMessage overrides the default "<perfName> timeout: extension took
|
||||
// too long to respond".
|
||||
timeoutMessage string
|
||||
// rawError returns non-timeout script errors unwrapped instead of
|
||||
// "<perfName> failed: %w".
|
||||
rawError bool
|
||||
}
|
||||
|
||||
// callExtension locks the extension's VM, runs opts.invoke, and hands
|
||||
// the raw result to parse while the VM lock is still held. parse is where
|
||||
// each caller does its type-specific parsing, perf.recordParse/setItems, and
|
||||
// any ProviderID stamping.
|
||||
func callExtension[T any](p *extensionProviderWrapper, opts extCallOpts, parse func(perf *extensionCallPerf, result goja.Value) (T, error)) (T, error) {
|
||||
var zero T
|
||||
|
||||
perf := newExtensionCallPerf(p.extension.ID, opts.perfName)
|
||||
defer perf.finish()
|
||||
initStartedAt := time.Now()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
perf.recordInit(time.Since(initStartedAt))
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
if opts.itemID != "" {
|
||||
if p.extension.runtime != nil {
|
||||
p.extension.runtime.setActiveDownloadItemID(opts.itemID)
|
||||
defer p.extension.runtime.clearActiveDownloadItemID()
|
||||
}
|
||||
initDownloadCancel(opts.itemID)
|
||||
defer clearDownloadCancel(opts.itemID)
|
||||
if isDownloadCancelled(opts.itemID) {
|
||||
return zero, ErrDownloadCancelled
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if opts.requestID != "" {
|
||||
if p.extension.runtime != nil {
|
||||
p.extension.runtime.setActiveRequestID(opts.requestID)
|
||||
defer p.extension.runtime.clearActiveRequestID()
|
||||
}
|
||||
ctx = initExtensionRequestCancel(opts.requestID)
|
||||
defer clearExtensionRequestCancel(opts.requestID)
|
||||
if isExtensionRequestCancelled(opts.requestID) {
|
||||
return zero, ErrExtensionRequestCancelled
|
||||
}
|
||||
}
|
||||
|
||||
if opts.beforeRun != nil {
|
||||
if cleanup := opts.beforeRun(); cleanup != nil {
|
||||
defer cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
jsStartedAt := time.Now()
|
||||
result, err := runGojaCallWithTimeoutContextAndRecover(ctx, p.vm, func() (goja.Value, error) {
|
||||
return opts.invoke(p.vm)
|
||||
}, opts.timeout)
|
||||
perf.recordJS(time.Since(jsStartedAt))
|
||||
perf.recordPayload(result)
|
||||
if err != nil {
|
||||
if IsRuntimeUnsafeError(err) {
|
||||
quarantineRuntimeLocked(p.extension, p.vm)
|
||||
}
|
||||
if opts.requestID != "" && isExtensionRequestCancelled(opts.requestID) {
|
||||
return zero, ErrExtensionRequestCancelled
|
||||
}
|
||||
if opts.itemID != "" && isDownloadCancelled(opts.itemID) {
|
||||
return zero, ErrDownloadCancelled
|
||||
}
|
||||
if opts.requestID != "" && errors.Is(err, ErrExtensionRequestCancelled) {
|
||||
return zero, ErrExtensionRequestCancelled
|
||||
}
|
||||
if IsTimeoutError(err) {
|
||||
if opts.timeoutMessage != "" {
|
||||
return zero, errors.New(opts.timeoutMessage)
|
||||
}
|
||||
return zero, fmt.Errorf("%s timeout: extension took too long to respond", opts.perfName)
|
||||
}
|
||||
if opts.rawError {
|
||||
return zero, err
|
||||
}
|
||||
return zero, fmt.Errorf("%s failed: %w", opts.perfName, err)
|
||||
}
|
||||
if opts.itemID != "" && isDownloadCancelled(opts.itemID) {
|
||||
return zero, ErrDownloadCancelled
|
||||
}
|
||||
if opts.requestID != "" && isExtensionRequestCancelled(opts.requestID) {
|
||||
return zero, ErrExtensionRequestCancelled
|
||||
}
|
||||
|
||||
return parse(perf, result)
|
||||
}
|
||||
|
||||
func invokeExtensionMethod(vm *goja.Runtime, method string, args ...any) (goja.Value, error) {
|
||||
extensionValue := vm.Get("extension")
|
||||
if gojaValueIsEmpty(extensionValue) {
|
||||
return goja.Null(), nil
|
||||
}
|
||||
|
||||
extensionObject := extensionValue.ToObject(vm)
|
||||
callable, ok := goja.AssertFunction(extensionObject.Get(method))
|
||||
if !ok {
|
||||
return goja.Null(), nil
|
||||
}
|
||||
|
||||
values := make([]goja.Value, len(args))
|
||||
for i, arg := range args {
|
||||
values[i] = gojaArgumentValue(vm, arg)
|
||||
}
|
||||
return callable(extensionObject, values...)
|
||||
}
|
||||
|
||||
func gojaArgumentValue(vm *goja.Runtime, value any) goja.Value {
|
||||
switch typed := value.(type) {
|
||||
case goja.Value:
|
||||
return typed
|
||||
case map[string]any:
|
||||
obj := vm.NewObject()
|
||||
for key, child := range typed {
|
||||
_ = obj.Set(key, gojaArgumentValue(vm, child))
|
||||
}
|
||||
return obj
|
||||
case map[string]string:
|
||||
obj := vm.NewObject()
|
||||
for key, child := range typed {
|
||||
_ = obj.Set(key, child)
|
||||
}
|
||||
return obj
|
||||
case []any:
|
||||
children := make([]any, len(typed))
|
||||
for i, child := range typed {
|
||||
children[i] = gojaArgumentValue(vm, child)
|
||||
}
|
||||
return vm.NewArray(children...)
|
||||
case []string:
|
||||
children := make([]any, len(typed))
|
||||
for i, child := range typed {
|
||||
children[i] = child
|
||||
}
|
||||
return vm.NewArray(children...)
|
||||
default:
|
||||
return vm.ToValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
func extensionMethodInvocation(method string, args ...any) func(*goja.Runtime) (goja.Value, error) {
|
||||
return func(vm *goja.Runtime) (goja.Value, error) {
|
||||
return invokeExtensionMethod(vm, method, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func hasExtensionMethod(vm *goja.Runtime, method string) bool {
|
||||
extensionValue := vm.Get("extension")
|
||||
if gojaValueIsEmpty(extensionValue) {
|
||||
return false
|
||||
}
|
||||
_, ok := goja.AssertFunction(extensionValue.ToObject(vm).Get(method))
|
||||
return ok
|
||||
}
|
||||
|
||||
func invokeExtensionOrGlobal(vm *goja.Runtime, method string, args ...any) (goja.Value, error) {
|
||||
if hasExtensionMethod(vm, method) {
|
||||
return invokeExtensionMethod(vm, method, args...)
|
||||
}
|
||||
callable, ok := goja.AssertFunction(vm.Get(method))
|
||||
if !ok {
|
||||
return goja.Null(), nil
|
||||
}
|
||||
values := make([]goja.Value, len(args))
|
||||
for i, arg := range args {
|
||||
values[i] = gojaArgumentValue(vm, arg)
|
||||
}
|
||||
return callable(vm.GlobalObject(), values...)
|
||||
}
|
||||
|
||||
func extensionTrackInput(track *ExtTrackMetadata) map[string]any {
|
||||
if track == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{
|
||||
"id": track.ID,
|
||||
"name": track.Name,
|
||||
"artists": track.Artists,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"album_id": track.AlbumID,
|
||||
"album_url": track.AlbumURL,
|
||||
"artist_id": track.ArtistID,
|
||||
"artist_url": track.ArtistURL,
|
||||
"external_urls": track.ExternalURL,
|
||||
"duration_ms": track.DurationMS,
|
||||
"cover_url": track.CoverURL,
|
||||
"preview_url": track.PreviewURL,
|
||||
"images": track.Images,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
"disc_number": track.DiscNumber,
|
||||
"total_discs": track.TotalDiscs,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType,
|
||||
"album_type": track.AlbumType,
|
||||
"explicit": track.Explicit,
|
||||
"tidal_id": track.TidalID,
|
||||
"qobuz_id": track.QobuzID,
|
||||
"deezer_id": track.DeezerID,
|
||||
"spotify_id": track.SpotifyID,
|
||||
"external_links": track.ExternalLinks,
|
||||
"label": track.Label,
|
||||
"copyright": track.Copyright,
|
||||
"genre": track.Genre,
|
||||
"composer": track.Composer,
|
||||
"audio_quality": track.AudioQuality,
|
||||
"audio_modes": track.AudioModes,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
||||
return p.SearchTracksForItemID(query, limit, "")
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) SearchTracksForItemID(query string, limit int, itemID string) (*ExtSearchResult, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
}
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
return callExtension(p, extCallOpts{
|
||||
perfName: "searchTracks",
|
||||
invoke: extensionMethodInvocation("searchTracks", query, limit),
|
||||
timeout: DefaultJSTimeout,
|
||||
itemID: itemID,
|
||||
}, func(perf *extensionCallPerf, result goja.Value) (*ExtSearchResult, error) {
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return nil, fmt.Errorf("searchTracks returned null")
|
||||
}
|
||||
parseStartedAt := time.Now()
|
||||
searchResult, err := parseExtensionSearchResult(p.vm, result)
|
||||
perf.recordParse(time.Since(parseStartedAt))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse search result: %w", err)
|
||||
}
|
||||
perf.setItems(len(searchResult.Tracks))
|
||||
|
||||
for i := range searchResult.Tracks {
|
||||
searchResult.Tracks[i].ProviderID = p.extension.ID
|
||||
}
|
||||
|
||||
return &searchResult, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
}
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
return callExtension(p, extCallOpts{
|
||||
perfName: "getTrack",
|
||||
invoke: extensionMethodInvocation("getTrack", trackID),
|
||||
timeout: DefaultJSTimeout,
|
||||
}, func(perf *extensionCallPerf, result goja.Value) (*ExtTrackMetadata, error) {
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return nil, fmt.Errorf("getTrack returned null")
|
||||
}
|
||||
parseStartedAt := time.Now()
|
||||
track := parseExtensionTrackValue(p.vm, result)
|
||||
perf.recordParse(time.Since(parseStartedAt))
|
||||
perf.setItems(1)
|
||||
track.ProviderID = p.extension.ID
|
||||
return &track, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
}
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
return callExtension(p, extCallOpts{
|
||||
perfName: "getAlbum",
|
||||
invoke: extensionMethodInvocation("getAlbum", albumID),
|
||||
timeout: DefaultJSTimeout,
|
||||
}, func(perf *extensionCallPerf, result goja.Value) (*ExtAlbumMetadata, error) {
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return nil, fmt.Errorf("getAlbum returned null")
|
||||
}
|
||||
parseStartedAt := time.Now()
|
||||
album, err := parseExtensionAlbumValue(p.vm, result)
|
||||
perf.recordParse(time.Since(parseStartedAt))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse album: %w", err)
|
||||
}
|
||||
perf.setItems(len(album.Tracks))
|
||||
|
||||
album.ProviderID = p.extension.ID
|
||||
for i := range album.Tracks {
|
||||
album.Tracks[i].ProviderID = p.extension.ID
|
||||
}
|
||||
return &album, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) GetPlaylist(playlistID string) (*ExtAlbumMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
}
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
return callExtension(p, extCallOpts{
|
||||
perfName: "getPlaylist",
|
||||
invoke: func(vm *goja.Runtime) (goja.Value, error) {
|
||||
if hasExtensionMethod(vm, "getPlaylist") {
|
||||
return invokeExtensionMethod(vm, "getPlaylist", playlistID)
|
||||
}
|
||||
return invokeExtensionMethod(vm, "getAlbum", playlistID)
|
||||
},
|
||||
timeout: DefaultJSTimeout,
|
||||
}, func(perf *extensionCallPerf, result goja.Value) (*ExtAlbumMetadata, error) {
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return nil, fmt.Errorf("getPlaylist returned null")
|
||||
}
|
||||
parseStartedAt := time.Now()
|
||||
playlist, err := parseExtensionAlbumValue(p.vm, result)
|
||||
perf.recordParse(time.Since(parseStartedAt))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse playlist: %w", err)
|
||||
}
|
||||
perf.setItems(len(playlist.Tracks))
|
||||
|
||||
playlist.ProviderID = p.extension.ID
|
||||
for i := range playlist.Tracks {
|
||||
playlist.Tracks[i].ProviderID = p.extension.ID
|
||||
}
|
||||
return &playlist, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
}
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
return callExtension(p, extCallOpts{
|
||||
perfName: "getArtist",
|
||||
invoke: extensionMethodInvocation("getArtist", artistID),
|
||||
timeout: DefaultJSTimeout,
|
||||
}, func(perf *extensionCallPerf, result goja.Value) (*ExtArtistMetadata, error) {
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return nil, fmt.Errorf("getArtist returned null")
|
||||
}
|
||||
parseStartedAt := time.Now()
|
||||
artist, err := parseExtensionArtistValue(p.vm, result)
|
||||
perf.recordParse(time.Since(parseStartedAt))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse artist: %w", err)
|
||||
}
|
||||
perf.setItems(len(artist.Albums) + len(artist.Releases) + len(artist.TopTracks))
|
||||
|
||||
artist.ProviderID = p.extension.ID
|
||||
for i := range artist.Releases {
|
||||
artist.Releases[i].ProviderID = p.extension.ID
|
||||
for j := range artist.Releases[i].Tracks {
|
||||
artist.Releases[i].Tracks[j].ProviderID = p.extension.ID
|
||||
}
|
||||
}
|
||||
return &artist, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
|
||||
return p.EnrichTrackForItemID(track, "")
|
||||
}
|
||||
|
||||
// EnrichTrackForItemID is excluded from the shared callExtension helper:
|
||||
// unlike the other providers it must return the original track (not an error)
|
||||
// on every failure path, which doesn't fit the helper's error-returning shape.
|
||||
func (p *extensionProviderWrapper) EnrichTrackForItemID(track *ExtTrackMetadata, itemID string) (*ExtTrackMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return track, nil
|
||||
}
|
||||
|
||||
if !p.extension.Enabled {
|
||||
return track, nil
|
||||
}
|
||||
perf := newExtensionCallPerf(p.extension.ID, "enrichTrack")
|
||||
defer perf.finish()
|
||||
initStartedAt := time.Now()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err)
|
||||
return track, nil
|
||||
}
|
||||
perf.recordInit(time.Since(initStartedAt))
|
||||
defer p.extension.VMMu.Unlock()
|
||||
if itemID != "" {
|
||||
if p.extension.runtime != nil {
|
||||
p.extension.runtime.setActiveDownloadItemID(itemID)
|
||||
defer p.extension.runtime.clearActiveDownloadItemID()
|
||||
}
|
||||
initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return track, ErrDownloadCancelled
|
||||
}
|
||||
}
|
||||
|
||||
jsStartedAt := time.Now()
|
||||
result, err := runGojaCallWithTimeoutAndRecover(p.vm, func() (goja.Value, error) {
|
||||
return invokeExtensionMethod(p.vm, "enrichTrack", extensionTrackInput(track))
|
||||
}, DefaultJSTimeout)
|
||||
perf.recordJS(time.Since(jsStartedAt))
|
||||
perf.recordPayload(result)
|
||||
if err != nil {
|
||||
if IsRuntimeUnsafeError(err) {
|
||||
quarantineRuntimeLocked(p.extension, p.vm)
|
||||
}
|
||||
if isDownloadCancelled(itemID) {
|
||||
return track, ErrDownloadCancelled
|
||||
}
|
||||
if IsTimeoutError(err) {
|
||||
GoLog("[Extension] EnrichTrack timeout for %s\n", p.extension.ID)
|
||||
} else {
|
||||
GoLog("[Extension] EnrichTrack error for %s: %v\n", p.extension.ID, err)
|
||||
}
|
||||
return track, nil
|
||||
}
|
||||
if isDownloadCancelled(itemID) {
|
||||
return track, ErrDownloadCancelled
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return track, nil
|
||||
}
|
||||
|
||||
parseStartedAt := time.Now()
|
||||
enrichedTrack := parseExtensionTrackValue(p.vm, result)
|
||||
perf.recordParse(time.Since(parseStartedAt))
|
||||
perf.setItems(1)
|
||||
enrichedTrack.ProviderID = track.ProviderID
|
||||
|
||||
return &enrichedTrack, nil
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) CheckAvailabilityForItemID(isrc, trackName, artistName, spotifyID, deezerID, tidalID, qobuzID string, durationMS int, itemID string) (*ExtAvailabilityResult, error) {
|
||||
if !p.extension.Manifest.IsDownloadProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||
}
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
availabilityOptions := map[string]any{
|
||||
"spotify_id": spotifyID,
|
||||
"deezer_id": deezerID,
|
||||
"tidal_id": tidalID,
|
||||
"qobuz_id": qobuzID,
|
||||
"duration_ms": durationMS,
|
||||
}
|
||||
|
||||
return callExtension(p, extCallOpts{
|
||||
perfName: "checkAvailability",
|
||||
invoke: extensionMethodInvocation("checkAvailability", isrc, trackName, artistName, availabilityOptions),
|
||||
timeout: DefaultJSTimeout,
|
||||
itemID: itemID,
|
||||
beforeRun: func() func() {
|
||||
// Drop any stale flag so the post-run check below only sees
|
||||
// verification requested by THIS call.
|
||||
if p.extension.runtime != nil {
|
||||
p.extension.runtime.consumeVerificationRequired()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}, func(perf *extensionCallPerf, result goja.Value) (*ExtAvailabilityResult, error) {
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return &ExtAvailabilityResult{Available: false, Reason: "not implemented"}, nil
|
||||
}
|
||||
parseStartedAt := time.Now()
|
||||
availability := parseExtensionAvailabilityValue(p.vm, result)
|
||||
perf.recordParse(time.Since(parseStartedAt))
|
||||
perf.setItems(1)
|
||||
// A signed-session call inside checkAvailability required
|
||||
// verification. Extensions often swallow that and report a plain
|
||||
// "not available", which would silently skip this provider's
|
||||
// challenge; surface it as an error so the fallback loop pauses and
|
||||
// opens the challenge instead.
|
||||
if !availability.Available && p.extension.runtime != nil {
|
||||
if p.extension.runtime.consumeVerificationRequired() != "" {
|
||||
return nil, fmt.Errorf(
|
||||
"VERIFY_REQUIRED: extension '%s' needs signed-session verification",
|
||||
p.extension.ID,
|
||||
)
|
||||
}
|
||||
}
|
||||
return &availability, nil
|
||||
})
|
||||
}
|
||||
|
||||
const ExtDownloadTimeout = DownloadTimeout
|
||||
|
||||
// Download is excluded from the shared callExtension helper: it runs in
|
||||
// an isolated VM/runtime (not p.vm/p.extension.VMMu) with a progress
|
||||
// callback, which the helper's lock+perf model doesn't cover.
|
||||
func (p *extensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) {
|
||||
if !p.extension.Manifest.IsDownloadProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID)
|
||||
}
|
||||
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
perf := newExtensionCallPerf(p.extension.ID, "download")
|
||||
defer perf.finish()
|
||||
initStartedAt := time.Now()
|
||||
vm, runtime, err := acquireIsolatedExtensionRuntime(p.extension)
|
||||
perf.recordInit(time.Since(initStartedAt))
|
||||
if err != nil {
|
||||
return &ExtDownloadResult{
|
||||
Success: false,
|
||||
ErrorMessage: err.Error(),
|
||||
ErrorType: "init_error",
|
||||
}, nil
|
||||
}
|
||||
vmHealthy := false
|
||||
cleanupSafe := true
|
||||
defer func() {
|
||||
releaseIsolatedExtensionRuntime(p.extension, vm, runtime, vmHealthy, cleanupSafe)
|
||||
}()
|
||||
if runtime != nil {
|
||||
runtime.setActiveDownloadItemID(itemID)
|
||||
defer runtime.clearActiveDownloadItemID()
|
||||
}
|
||||
if itemID != "" {
|
||||
initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
SetItemPreparing(itemID)
|
||||
}
|
||||
|
||||
progressCallback := vm.ToValue(func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) > 0 {
|
||||
percent := int(call.Arguments[0].ToInteger())
|
||||
if percent < 0 {
|
||||
percent = 0
|
||||
}
|
||||
if percent > 100 {
|
||||
percent = 100
|
||||
}
|
||||
if onProgress != nil {
|
||||
onProgress(percent)
|
||||
}
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
if runtime != nil {
|
||||
// Drop any stale flag (pooled runtimes survive across downloads) so
|
||||
// the post-run check only sees verification from THIS call.
|
||||
runtime.consumeVerificationRequired()
|
||||
}
|
||||
|
||||
jsStartedAt := time.Now()
|
||||
result, err := runGojaCallWithTimeoutAndRecover(vm, func() (goja.Value, error) {
|
||||
return invokeExtensionMethod(vm, "download", trackID, quality, outputPath, progressCallback)
|
||||
}, ExtDownloadTimeout)
|
||||
perf.recordJS(time.Since(jsStartedAt))
|
||||
perf.recordPayload(result)
|
||||
vmHealthy = err == nil
|
||||
cleanupSafe = !IsRuntimeUnsafeError(err)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
errType := "script_error"
|
||||
if IsTimeoutError(err) {
|
||||
errMsg = "download timeout: extension took too long to complete"
|
||||
errType = "timeout"
|
||||
}
|
||||
return &ExtDownloadResult{
|
||||
Success: false,
|
||||
ErrorMessage: errMsg,
|
||||
ErrorType: errType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return &ExtDownloadResult{
|
||||
Success: false,
|
||||
ErrorMessage: "download returned null",
|
||||
ErrorType: "not_implemented",
|
||||
}, nil
|
||||
}
|
||||
|
||||
parseStartedAt := time.Now()
|
||||
downloadResult := parseExtensionDownloadResultValue(vm, result)
|
||||
perf.recordParse(time.Since(parseStartedAt))
|
||||
perf.setItems(1)
|
||||
downloadResult.Decryption = normalizeDownloadDecryptionInfo(
|
||||
downloadResult.Decryption,
|
||||
downloadResult.DecryptionKey,
|
||||
)
|
||||
downloadResult.DecryptionKey = normalizedDownloadDecryptionKey(
|
||||
downloadResult.Decryption,
|
||||
downloadResult.DecryptionKey,
|
||||
)
|
||||
|
||||
// A signed-session call inside download() required verification but the
|
||||
// script reported a generic failure; tag the result so the fallback loop
|
||||
// pauses and opens this provider's challenge instead of skipping it.
|
||||
if runtime != nil && !downloadResult.Success {
|
||||
if runtime.consumeVerificationRequired() != "" &&
|
||||
!strings.EqualFold(downloadResult.ErrorType, "verification_required") {
|
||||
downloadResult.ErrorType = "verification_required"
|
||||
if downloadResult.ErrorMessage == "" {
|
||||
downloadResult.ErrorMessage = "Verification required"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &downloadResult, nil
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]any) ([]ExtTrackMetadata, error) {
|
||||
return p.customSearch(query, options, "", "")
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) CustomSearchForRequestID(query string, options map[string]any, requestID string) ([]ExtTrackMetadata, error) {
|
||||
return p.customSearch(query, options, "", requestID)
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) customSearch(query string, options map[string]any, itemID, requestID string) ([]ExtTrackMetadata, error) {
|
||||
if !p.extension.Manifest.HasCustomSearch() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support custom search", p.extension.ID)
|
||||
}
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
if options == nil {
|
||||
options = map[string]any{}
|
||||
}
|
||||
|
||||
return callExtension(p, extCallOpts{
|
||||
perfName: "customSearch",
|
||||
invoke: extensionMethodInvocation("customSearch", query, options),
|
||||
timeout: DefaultJSTimeout,
|
||||
itemID: itemID,
|
||||
requestID: requestID,
|
||||
}, func(perf *extensionCallPerf, result goja.Value) ([]ExtTrackMetadata, error) {
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return []ExtTrackMetadata{}, nil
|
||||
}
|
||||
parseStartedAt := time.Now()
|
||||
tracks, err := parseExtensionTrackArray(p.vm, result)
|
||||
perf.recordParse(time.Since(parseStartedAt))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse search result: %w", err)
|
||||
}
|
||||
perf.setItems(len(tracks))
|
||||
|
||||
for i := range tracks {
|
||||
tracks[i].ProviderID = p.extension.ID
|
||||
}
|
||||
|
||||
return tracks, nil
|
||||
})
|
||||
}
|
||||
|
||||
type ExtURLHandleResult struct {
|
||||
Type string `json:"type"`
|
||||
Track *ExtTrackMetadata `json:"track,omitempty"`
|
||||
Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
|
||||
Album *ExtAlbumMetadata `json:"album,omitempty"`
|
||||
Artist *ExtArtistMetadata `json:"artist,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
HeaderImage string `json:"header_image,omitempty"`
|
||||
HeaderVideo string `json:"header_video,omitempty"`
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
|
||||
if !p.extension.Manifest.HasURLHandler() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support URL handling", p.extension.ID)
|
||||
}
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
return callExtension(p, extCallOpts{
|
||||
perfName: "handleUrl",
|
||||
invoke: extensionMethodInvocation("handleUrl", url),
|
||||
timeout: DefaultJSTimeout,
|
||||
}, func(perf *extensionCallPerf, result goja.Value) (*ExtURLHandleResult, error) {
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return nil, fmt.Errorf("handleUrl returned null - URL not recognized")
|
||||
}
|
||||
parseStartedAt := time.Now()
|
||||
handleResult, err := parseExtensionURLHandleValue(p.vm, result)
|
||||
perf.recordParse(time.Since(parseStartedAt))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse URL handle result: %w", err)
|
||||
}
|
||||
urlItems := len(handleResult.Tracks)
|
||||
if handleResult.Track != nil {
|
||||
urlItems++
|
||||
}
|
||||
if handleResult.Album != nil {
|
||||
urlItems += 1 + len(handleResult.Album.Tracks)
|
||||
}
|
||||
if handleResult.Artist != nil {
|
||||
urlItems += 1 + len(handleResult.Artist.Albums) + len(handleResult.Artist.Releases) + len(handleResult.Artist.TopTracks)
|
||||
}
|
||||
perf.setItems(urlItems)
|
||||
|
||||
if handleResult.Track != nil {
|
||||
handleResult.Track.ProviderID = p.extension.ID
|
||||
}
|
||||
for i := range handleResult.Tracks {
|
||||
handleResult.Tracks[i].ProviderID = p.extension.ID
|
||||
}
|
||||
if handleResult.Album != nil {
|
||||
handleResult.Album.ProviderID = p.extension.ID
|
||||
for i := range handleResult.Album.Tracks {
|
||||
handleResult.Album.Tracks[i].ProviderID = p.extension.ID
|
||||
}
|
||||
}
|
||||
if handleResult.Artist != nil {
|
||||
handleResult.Artist.ProviderID = p.extension.ID
|
||||
for i := range handleResult.Artist.Albums {
|
||||
handleResult.Artist.Albums[i].ProviderID = p.extension.ID
|
||||
for j := range handleResult.Artist.Albums[i].Tracks {
|
||||
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
|
||||
}
|
||||
}
|
||||
for i := range handleResult.Artist.Releases {
|
||||
handleResult.Artist.Releases[i].ProviderID = p.extension.ID
|
||||
for j := range handleResult.Artist.Releases[i].Tracks {
|
||||
handleResult.Artist.Releases[i].Tracks[j].ProviderID = p.extension.ID
|
||||
}
|
||||
}
|
||||
for i := range handleResult.Artist.TopTracks {
|
||||
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
|
||||
}
|
||||
}
|
||||
|
||||
return &handleResult, nil
|
||||
})
|
||||
}
|
||||
|
||||
type PostProcessResult struct {
|
||||
Success bool `json:"success"`
|
||||
NewFilePath string `json:"new_file_path,omitempty"`
|
||||
NewFileURI string `json:"new_file_uri,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
BitDepth int `json:"bit_depth,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
}
|
||||
|
||||
type PostProcessInput struct {
|
||||
Path string `json:"path,omitempty"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
IsSAF bool `json:"is_saf,omitempty"`
|
||||
}
|
||||
|
||||
func postProcessInputMap(input PostProcessInput) map[string]any {
|
||||
result := make(map[string]any, 6)
|
||||
if input.Path != "" {
|
||||
result["path"] = input.Path
|
||||
}
|
||||
if input.URI != "" {
|
||||
result["uri"] = input.URI
|
||||
}
|
||||
if input.Name != "" {
|
||||
result["name"] = input.Name
|
||||
}
|
||||
if input.MimeType != "" {
|
||||
result["mime_type"] = input.MimeType
|
||||
}
|
||||
if input.Size != 0 {
|
||||
result["size"] = input.Size
|
||||
}
|
||||
if input.IsSAF {
|
||||
result["is_saf"] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const PostProcessTimeout = 2 * time.Minute
|
||||
|
||||
// postProcessCommon backs both PostProcess (V1) and PostProcessV2. V1 probes
|
||||
// only extension.postProcess (its original contract: V2-only extensions are
|
||||
// not invoked via V1); V2 probes postProcessV2 first, then falls back to
|
||||
// postProcess.
|
||||
func (p *extensionProviderWrapper) postProcessCommon(input PostProcessInput, metadata map[string]any, hookID string, preferV2 bool) (*PostProcessResult, error) {
|
||||
if !p.extension.Manifest.HasPostProcessing() {
|
||||
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
|
||||
}
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
filePath := input.Path
|
||||
|
||||
perfName := "postProcess"
|
||||
var invoke func(*goja.Runtime) (goja.Value, error)
|
||||
if preferV2 {
|
||||
perfName = "postProcessV2"
|
||||
inputMap := postProcessInputMap(input)
|
||||
invoke = func(vm *goja.Runtime) (goja.Value, error) {
|
||||
if hasExtensionMethod(vm, "postProcessV2") {
|
||||
return invokeExtensionMethod(vm, "postProcessV2", inputMap, metadata, hookID)
|
||||
}
|
||||
return invokeExtensionMethod(vm, "postProcess", filePath, metadata, hookID)
|
||||
}
|
||||
} else {
|
||||
invoke = extensionMethodInvocation("postProcess", filePath, metadata, hookID)
|
||||
}
|
||||
|
||||
result, err := callExtension(p, extCallOpts{
|
||||
perfName: perfName,
|
||||
invoke: invoke,
|
||||
timeout: PostProcessTimeout,
|
||||
timeoutMessage: "postProcess timeout: extension took too long to complete",
|
||||
rawError: true,
|
||||
}, func(perf *extensionCallPerf, value goja.Value) (*PostProcessResult, error) {
|
||||
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return &PostProcessResult{Success: false, Error: "postProcess returned null"}, nil
|
||||
}
|
||||
parseStartedAt := time.Now()
|
||||
postResult := parseExtensionPostProcessValue(p.vm, value)
|
||||
perf.recordParse(time.Since(parseStartedAt))
|
||||
perf.setItems(1)
|
||||
return &postResult, nil
|
||||
})
|
||||
if err != nil {
|
||||
return &PostProcessResult{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) PostProcess(filePath string, metadata map[string]any, hookID string) (*PostProcessResult, error) {
|
||||
return p.postProcessCommon(PostProcessInput{Path: filePath}, metadata, hookID, false)
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]any, hookID string) (*PostProcessResult, error) {
|
||||
return p.postProcessCommon(input, metadata, hookID, true)
|
||||
}
|
||||
|
||||
type ExtLyricsResult struct {
|
||||
Lines []ExtLyricsLine `json:"lines"`
|
||||
SyncType string `json:"syncType"`
|
||||
Instrumental bool `json:"instrumental"`
|
||||
PlainLyrics string `json:"plainLyrics"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
|
||||
type ExtLyricsLine struct {
|
||||
StartTimeMs int64 `json:"startTimeMs"`
|
||||
Words string `json:"words"`
|
||||
EndTimeMs int64 `json:"endTimeMs"`
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) {
|
||||
if !p.extension.Manifest.IsLyricsProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID)
|
||||
}
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
return callExtension(p, extCallOpts{
|
||||
perfName: "fetchLyrics",
|
||||
invoke: extensionMethodInvocation("fetchLyrics", trackName, artistName, albumName, durationSec),
|
||||
timeout: DefaultJSTimeout,
|
||||
}, func(perf *extensionCallPerf, result goja.Value) (*LyricsResponse, error) {
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return nil, fmt.Errorf("fetchLyrics returned null")
|
||||
}
|
||||
parseStartedAt := time.Now()
|
||||
extResult, err := parseExtensionLyricsValue(p.vm, result)
|
||||
perf.recordParse(time.Since(parseStartedAt))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse lyrics result: %w", err)
|
||||
}
|
||||
perf.setItems(len(extResult.Lines))
|
||||
|
||||
response := &LyricsResponse{
|
||||
SyncType: extResult.SyncType,
|
||||
Instrumental: extResult.Instrumental,
|
||||
PlainLyrics: extResult.PlainLyrics,
|
||||
Provider: extResult.Provider,
|
||||
Source: "Extension: " + p.extension.ID,
|
||||
}
|
||||
|
||||
if response.Provider == "" {
|
||||
response.Provider = p.extension.Manifest.DisplayName
|
||||
}
|
||||
|
||||
for _, line := range extResult.Lines {
|
||||
response.Lines = append(response.Lines, LyricsLine(line))
|
||||
}
|
||||
|
||||
if len(response.Lines) == 0 && response.PlainLyrics != "" && !response.Instrumental {
|
||||
response.SyncType = "UNSYNCED"
|
||||
for _, line := range strings.Split(response.PlainLyrics, "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
response.Lines = append(response.Lines, LyricsLine{
|
||||
StartTimeMs: 0,
|
||||
Words: line,
|
||||
EndTimeMs: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response, nil
|
||||
})
|
||||
}
|
||||
+3158
-58
File diff suppressed because it is too large
Load Diff
@@ -745,6 +745,15 @@ func TestParseExtensionURLHandleResult(t *testing.T) {
|
||||
func TestParseExtensionAuxiliaryResults(t *testing.T) {
|
||||
vm := goja.New()
|
||||
|
||||
matchValue, err := vm.RunString(`({ matched: true, trackId: "track-1", confidence: 0.92, reason: "isrc" })`)
|
||||
if err != nil {
|
||||
t.Fatalf("build match value: %v", err)
|
||||
}
|
||||
match := parseExtensionMatchTrackValue(vm, matchValue)
|
||||
if !match.Matched || match.TrackID != "track-1" || match.Confidence != 0.92 || match.Reason != "isrc" {
|
||||
t.Fatalf("unexpected match result: %+v", match)
|
||||
}
|
||||
|
||||
postValue, err := vm.RunString(`({ success: true, newFilePath: "/tmp/new.flac", newFileUri: "content://new", bitDepth: 24, sampleRate: 96000 })`)
|
||||
if err != nil {
|
||||
t.Fatalf("build post-process value: %v", err)
|
||||
|
||||
+80
-208
@@ -1,45 +1,18 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// allowPrivateNetworkAccess, when enabled, disables the SSRF guard that blocks
|
||||
// requests resolving to private/local/loopback addresses. This is opt-in and
|
||||
// intended for users who route the app's traffic through a local proxy or
|
||||
// custom DNS (e.g. a local mirror of api.zarz.moe). Disabled by default.
|
||||
var allowPrivateNetworkAccess atomic.Bool
|
||||
|
||||
// SetAllowPrivateNetwork toggles whether extensions and built-in network code
|
||||
// are permitted to reach private/local network targets. Exposed to the Flutter
|
||||
// layer via the platform bridge.
|
||||
func SetAllowPrivateNetwork(allowed bool) {
|
||||
allowPrivateNetworkAccess.Store(allowed)
|
||||
if allowed {
|
||||
GoLog("[HTTP] Private/local network access ENABLED (SSRF guard relaxed)\n")
|
||||
} else {
|
||||
GoLog("[HTTP] Private/local network access disabled (default)\n")
|
||||
}
|
||||
}
|
||||
|
||||
// IsPrivateNetworkAllowed reports the current state of the private-network guard.
|
||||
func IsPrivateNetworkAllowed() bool {
|
||||
return allowPrivateNetworkAccess.Load()
|
||||
}
|
||||
|
||||
const DefaultJSTimeout = 30 * time.Second
|
||||
|
||||
var (
|
||||
@@ -62,13 +35,8 @@ type PendingAuthRequest struct {
|
||||
ExtensionID string
|
||||
AuthURL string
|
||||
CallbackURL string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Challenge URLs are short-lived; serving one past this age sends the user
|
||||
// to an already-expired verification page.
|
||||
const pendingAuthRequestTTL = 5 * time.Minute
|
||||
|
||||
var (
|
||||
pendingAuthRequests = make(map[string]*PendingAuthRequest)
|
||||
pendingAuthRequestsMu sync.RWMutex
|
||||
@@ -116,7 +84,7 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
||||
type extensionRuntime struct {
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]any
|
||||
settings map[string]interface{}
|
||||
httpClient *http.Client
|
||||
downloadClient *http.Client
|
||||
cookieJar http.CookieJar
|
||||
@@ -129,40 +97,18 @@ type extensionRuntime struct {
|
||||
activeRequestMu sync.RWMutex
|
||||
activeRequestID string
|
||||
|
||||
storageMu sync.RWMutex
|
||||
storageCache map[string]any
|
||||
storageClosed bool
|
||||
storageMu sync.RWMutex
|
||||
storageCache map[string]interface{}
|
||||
storageLoaded bool
|
||||
storageDirty bool
|
||||
storageClosed bool
|
||||
storageTimer *time.Timer
|
||||
storageWriteMu sync.Mutex
|
||||
|
||||
credentialsMu sync.RWMutex
|
||||
credentialsCache map[string]any
|
||||
|
||||
// Set when a signed-session call inside the current script invocation
|
||||
// required verification. The provider wrapper consumes it after the
|
||||
// script returns, so verification surfaces even when the extension
|
||||
// script swallowed the needsVerification response (issue: fallback
|
||||
// skipped provider B's challenge and failed outright).
|
||||
verificationMu sync.Mutex
|
||||
verificationRequiredURL string
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) noteVerificationRequired(authURL string) {
|
||||
r.verificationMu.Lock()
|
||||
if authURL == "" {
|
||||
authURL = "pending"
|
||||
}
|
||||
r.verificationRequiredURL = authURL
|
||||
r.verificationMu.Unlock()
|
||||
}
|
||||
|
||||
// consumeVerificationRequired returns the noted auth URL (or "pending") and
|
||||
// clears the flag; "" means no verification was requested since the last
|
||||
// consume.
|
||||
func (r *extensionRuntime) consumeVerificationRequired() string {
|
||||
r.verificationMu.Lock()
|
||||
url := r.verificationRequiredURL
|
||||
r.verificationRequiredURL = ""
|
||||
r.verificationMu.Unlock()
|
||||
return url
|
||||
credentialsMu sync.RWMutex
|
||||
credentialsCache map[string]interface{}
|
||||
credentialsLoaded bool
|
||||
storageFlushDelay time.Duration
|
||||
}
|
||||
|
||||
type privateIPCacheEntry struct {
|
||||
@@ -181,22 +127,17 @@ var (
|
||||
privateIPCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
func clearPrivateIPCache() {
|
||||
privateIPCacheMu.Lock()
|
||||
privateIPCache = make(map[string]privateIPCacheEntry)
|
||||
privateIPCacheMu.Unlock()
|
||||
}
|
||||
|
||||
func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
|
||||
jar, _ := newSimpleCookieJar()
|
||||
|
||||
runtime := &extensionRuntime{
|
||||
extensionID: ext.ID,
|
||||
manifest: ext.Manifest,
|
||||
settings: make(map[string]any),
|
||||
cookieJar: jar,
|
||||
dataDir: ext.DataDir,
|
||||
vm: ext.VM,
|
||||
extensionID: ext.ID,
|
||||
manifest: ext.Manifest,
|
||||
settings: make(map[string]interface{}),
|
||||
cookieJar: jar,
|
||||
dataDir: ext.DataDir,
|
||||
vm: ext.VM,
|
||||
storageFlushDelay: defaultStorageFlushDelay,
|
||||
}
|
||||
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second), true)
|
||||
@@ -230,7 +171,7 @@ func extensionHTTPTimeout(ext *loadedExtension, fallback time.Duration) time.Dur
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func parseExtensionTimeoutSeconds(raw any) int {
|
||||
func parseExtensionTimeoutSeconds(raw interface{}) int {
|
||||
switch v := raw.(type) {
|
||||
case int:
|
||||
return v
|
||||
@@ -300,53 +241,10 @@ func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Re
|
||||
if requestID == "" {
|
||||
return req
|
||||
}
|
||||
return req.WithContext(extensionRequestCancelContext(requestID))
|
||||
return req.WithContext(initExtensionRequestCancel(requestID))
|
||||
}
|
||||
|
||||
return req.WithContext(downloadCancelContext(itemID))
|
||||
}
|
||||
|
||||
// downloadStallTimeout is how long a download may go without receiving a single
|
||||
// byte before the stall watchdog aborts it. A dead radio mid-transfer otherwise
|
||||
// blocks on Body.Read until the 24h client timeout with no error and no retry.
|
||||
const downloadStallTimeout = 60 * time.Second
|
||||
|
||||
// stallWatchdog cancels an in-flight download when no data arrives within
|
||||
// timeout. It wraps the request context in a child cancel so firing it does NOT
|
||||
// set the user-cancel flag (isDownloadCancelled stays false) — a stall is a
|
||||
// distinct, retryable condition. Call reset() after every successful Read and
|
||||
// stop() when the transfer ends.
|
||||
type stallWatchdog struct {
|
||||
cancel context.CancelFunc
|
||||
timer *time.Timer
|
||||
timeout time.Duration
|
||||
stalled atomic.Bool
|
||||
}
|
||||
|
||||
func bindStallWatchdog(req *http.Request, timeout time.Duration) (*http.Request, *stallWatchdog) {
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
w := &stallWatchdog{cancel: cancel, timeout: timeout}
|
||||
w.timer = time.AfterFunc(timeout, func() {
|
||||
w.stalled.Store(true)
|
||||
cancel()
|
||||
})
|
||||
return req.WithContext(ctx), w
|
||||
}
|
||||
|
||||
func (w *stallWatchdog) reset() { w.timer.Reset(w.timeout) }
|
||||
|
||||
// stop halts the timer and releases the child context so a completed download
|
||||
// leaks neither a pending timer nor a live cancel func.
|
||||
func (w *stallWatchdog) stop() {
|
||||
w.timer.Stop()
|
||||
w.cancel()
|
||||
}
|
||||
|
||||
// stallError is returned when the watchdog fires. The message is deliberately
|
||||
// free of "cancel" and worded to classify as retryable network failure, so the
|
||||
// fallback layer retries instead of treating it as a user cancellation.
|
||||
func (r *extensionRuntime) stallError() goja.Value {
|
||||
return r.jsError("download stalled: no data received for %ds (network timeout)", int(downloadStallTimeout.Seconds()))
|
||||
return req.WithContext(initDownloadCancel(itemID))
|
||||
}
|
||||
|
||||
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration, compressResponses bool) *http.Client {
|
||||
@@ -405,12 +303,6 @@ func (e *RedirectBlockedError) Error() string {
|
||||
}
|
||||
|
||||
func isPrivateIP(host string) bool {
|
||||
// Opt-in escape hatch: when the user has enabled private/local network
|
||||
// access, treat every host as public so local proxies / custom DNS work.
|
||||
if allowPrivateNetworkAccess.Load() {
|
||||
return false
|
||||
}
|
||||
|
||||
hostLower := strings.ToLower(strings.TrimSpace(host))
|
||||
if hostLower == "" {
|
||||
return false
|
||||
@@ -434,7 +326,13 @@ func isPrivateIP(host string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
isPrivate := slices.ContainsFunc(ips, isPrivateIPAddr)
|
||||
isPrivate := false
|
||||
for _, ip := range ips {
|
||||
if isPrivateIPAddr(ip) {
|
||||
isPrivate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
setPrivateIPCache(hostLower, isPrivate, privateIPCacheTTL)
|
||||
return isPrivate
|
||||
@@ -501,39 +399,30 @@ func isPrivateIPAddr(ip net.IP) bool {
|
||||
}
|
||||
|
||||
type simpleCookieJar struct {
|
||||
mu sync.RWMutex
|
||||
jar *cookiejar.Jar
|
||||
cookies map[string][]*http.Cookie
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func newSimpleCookieJar() (*simpleCookieJar, error) {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &simpleCookieJar{jar: jar}, nil
|
||||
return &simpleCookieJar{
|
||||
cookies: make(map[string][]*http.Cookie),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
j.jar.SetCookies(u, cookies)
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
key := u.Host
|
||||
j.cookies[key] = append(j.cookies[key], cookies...)
|
||||
}
|
||||
|
||||
func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
||||
j.mu.RLock()
|
||||
defer j.mu.RUnlock()
|
||||
return j.jar.Cookies(u)
|
||||
|
||||
return j.cookies[u.Host]
|
||||
}
|
||||
|
||||
func (j *simpleCookieJar) Clear() {
|
||||
jar, _ := cookiejar.New(nil)
|
||||
j.mu.Lock()
|
||||
j.jar = jar
|
||||
j.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) SetSettings(settings map[string]any) {
|
||||
func (r *extensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
r.settings = settings
|
||||
}
|
||||
|
||||
@@ -550,67 +439,50 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
httpObj.Set("clearCookies", r.httpClearCookies)
|
||||
vm.Set("http", httpObj)
|
||||
|
||||
if r.manifest != nil && r.manifest.Permissions.Storage {
|
||||
storageObj := vm.NewObject()
|
||||
storageObj.Set("get", r.storageGet)
|
||||
storageObj.Set("set", r.storageSet)
|
||||
storageObj.Set("remove", r.storageRemove)
|
||||
vm.Set("storage", storageObj)
|
||||
storageObj := vm.NewObject()
|
||||
storageObj.Set("get", r.storageGet)
|
||||
storageObj.Set("set", r.storageSet)
|
||||
storageObj.Set("remove", r.storageRemove)
|
||||
vm.Set("storage", storageObj)
|
||||
|
||||
credentialsObj := vm.NewObject()
|
||||
credentialsObj.Set("store", r.credentialsStore)
|
||||
credentialsObj.Set("get", r.credentialsGet)
|
||||
credentialsObj.Set("remove", r.credentialsRemove)
|
||||
credentialsObj.Set("has", r.credentialsHas)
|
||||
vm.Set("credentials", credentialsObj)
|
||||
}
|
||||
credentialsObj := vm.NewObject()
|
||||
credentialsObj.Set("store", r.credentialsStore)
|
||||
credentialsObj.Set("get", r.credentialsGet)
|
||||
credentialsObj.Set("remove", r.credentialsRemove)
|
||||
credentialsObj.Set("has", r.credentialsHas)
|
||||
vm.Set("credentials", credentialsObj)
|
||||
|
||||
if r.manifest != nil && r.manifest.Permissions.Storage {
|
||||
authObj := vm.NewObject()
|
||||
authObj.Set("openAuthUrl", r.authOpenUrl)
|
||||
authObj.Set("getAuthCode", r.authGetCode)
|
||||
authObj.Set("setAuthCode", r.authSetCode)
|
||||
authObj.Set("clearAuth", r.authClear)
|
||||
authObj.Set("isAuthenticated", r.authIsAuthenticated)
|
||||
authObj.Set("getTokens", r.authGetTokens)
|
||||
authObj.Set("generatePKCE", r.authGeneratePKCE)
|
||||
authObj.Set("getPKCE", r.authGetPKCE)
|
||||
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
|
||||
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||
vm.Set("auth", authObj)
|
||||
authObj := vm.NewObject()
|
||||
authObj.Set("openAuthUrl", r.authOpenUrl)
|
||||
authObj.Set("getAuthCode", r.authGetCode)
|
||||
authObj.Set("setAuthCode", r.authSetCode)
|
||||
authObj.Set("clearAuth", r.authClear)
|
||||
authObj.Set("isAuthenticated", r.authIsAuthenticated)
|
||||
authObj.Set("getTokens", r.authGetTokens)
|
||||
authObj.Set("generatePKCE", r.authGeneratePKCE)
|
||||
authObj.Set("getPKCE", r.authGetPKCE)
|
||||
authObj.Set("startOAuthWithPKCE", r.authStartOAuthWithPKCE)
|
||||
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||
vm.Set("auth", authObj)
|
||||
|
||||
if r.manifest.SignedSession != nil {
|
||||
sessionObj := vm.NewObject()
|
||||
sessionObj.Set("signedFetch", r.signedSessionFetch)
|
||||
sessionObj.Set("completeGrant", r.signedSessionCompleteGrant)
|
||||
sessionObj.Set("status", r.signedSessionStatus)
|
||||
sessionObj.Set("clear", r.signedSessionClear)
|
||||
vm.Set("session", sessionObj)
|
||||
}
|
||||
}
|
||||
fileObj := vm.NewObject()
|
||||
fileObj.Set("download", r.fileDownload)
|
||||
fileObj.Set("exists", r.fileExists)
|
||||
fileObj.Set("delete", r.fileDelete)
|
||||
fileObj.Set("read", r.fileRead)
|
||||
fileObj.Set("readBytes", r.fileReadBytes)
|
||||
fileObj.Set("write", r.fileWrite)
|
||||
fileObj.Set("writeBytes", r.fileWriteBytes)
|
||||
fileObj.Set("copy", r.fileCopy)
|
||||
fileObj.Set("move", r.fileMove)
|
||||
fileObj.Set("getSize", r.fileGetSize)
|
||||
vm.Set("file", fileObj)
|
||||
|
||||
if r.manifest != nil && r.manifest.Permissions.File {
|
||||
fileObj := vm.NewObject()
|
||||
fileObj.Set("download", r.fileDownload)
|
||||
fileObj.Set("exists", r.fileExists)
|
||||
fileObj.Set("delete", r.fileDelete)
|
||||
fileObj.Set("read", r.fileRead)
|
||||
fileObj.Set("readBytes", r.fileReadBytes)
|
||||
fileObj.Set("write", r.fileWrite)
|
||||
fileObj.Set("writeBytes", r.fileWriteBytes)
|
||||
fileObj.Set("copy", r.fileCopy)
|
||||
fileObj.Set("move", r.fileMove)
|
||||
fileObj.Set("getSize", r.fileGetSize)
|
||||
vm.Set("file", fileObj)
|
||||
|
||||
ffmpegObj := vm.NewObject()
|
||||
if r.manifest.HasCapability("rawFfmpeg") {
|
||||
ffmpegObj.Set("execute", r.ffmpegExecute)
|
||||
}
|
||||
ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
|
||||
ffmpegObj.Set("convert", r.ffmpegConvert)
|
||||
vm.Set("ffmpeg", ffmpegObj)
|
||||
}
|
||||
ffmpegObj := vm.NewObject()
|
||||
ffmpegObj.Set("execute", r.ffmpegExecute)
|
||||
ffmpegObj.Set("getInfo", r.ffmpegGetInfo)
|
||||
ffmpegObj.Set("convert", r.ffmpegConvert)
|
||||
vm.Set("ffmpeg", ffmpegObj)
|
||||
|
||||
matchingObj := vm.NewObject()
|
||||
matchingObj.Set("compareStrings", r.matchingCompareStrings)
|
||||
|
||||
@@ -54,7 +54,10 @@ func summarizeURLForLog(urlStr string) string {
|
||||
|
||||
func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.jsError("auth URL is required")
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "auth URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
authURL := call.Arguments[0].String()
|
||||
@@ -64,7 +67,10 @@ func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
|
||||
if err := validateExtensionAuthURL(authURL); err != nil {
|
||||
return r.jsError("%s", err.Error())
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
pendingAuthRequestsMu.Lock()
|
||||
@@ -72,7 +78,6 @@ func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
ExtensionID: r.extensionID,
|
||||
AuthURL: authURL,
|
||||
CallbackURL: callbackURL,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
@@ -88,7 +93,8 @@ func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
|
||||
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, summarizeURLForLog(authURL))
|
||||
|
||||
return r.jsSuccess(map[string]any{
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Auth URL will be opened by the app",
|
||||
})
|
||||
}
|
||||
@@ -124,7 +130,7 @@ func (r *extensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
switch v := arg.(type) {
|
||||
case string:
|
||||
state.AuthCode = v
|
||||
case map[string]any:
|
||||
case map[string]interface{}:
|
||||
if code, ok := v["code"].(string); ok {
|
||||
state.AuthCode = code
|
||||
}
|
||||
@@ -178,10 +184,10 @@ func (r *extensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists {
|
||||
return r.vm.ToValue(map[string]any{})
|
||||
return r.vm.ToValue(map[string]interface{}{})
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
result := map[string]interface{}{
|
||||
"access_token": state.AccessToken,
|
||||
"refresh_token": state.RefreshToken,
|
||||
"is_authenticated": state.IsAuthenticated,
|
||||
@@ -233,7 +239,7 @@ func (r *extensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
verifier, err := generatePKCEVerifier(length)
|
||||
if err != nil {
|
||||
GoLog("[Extension:%s] PKCE generation error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]any{
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
@@ -252,7 +258,7 @@ func (r *extensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
|
||||
GoLog("[Extension:%s] PKCE generated (verifier length: %d)\n", r.extensionID, len(verifier))
|
||||
|
||||
return r.vm.ToValue(map[string]any{
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"verifier": verifier,
|
||||
"challenge": challenge,
|
||||
"method": "S256",
|
||||
@@ -265,10 +271,10 @@ func (r *extensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
|
||||
state, exists := extensionAuthState[r.extensionID]
|
||||
if !exists || state.PKCEVerifier == "" {
|
||||
return r.vm.ToValue(map[string]any{})
|
||||
return r.vm.ToValue(map[string]interface{}{})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]any{
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"verifier": state.PKCEVerifier,
|
||||
"challenge": state.PKCEChallenge,
|
||||
"method": "S256",
|
||||
@@ -277,13 +283,19 @@ func (r *extensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
|
||||
func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.jsError("config object is required")
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config object is required",
|
||||
})
|
||||
}
|
||||
|
||||
configObj := call.Arguments[0].Export()
|
||||
config, ok := configObj.(map[string]any)
|
||||
config, ok := configObj.(map[string]interface{})
|
||||
if !ok {
|
||||
return r.jsError("config must be an object")
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config must be an object",
|
||||
})
|
||||
}
|
||||
|
||||
authURL, _ := config["authUrl"].(string)
|
||||
@@ -291,18 +303,27 @@ func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
redirectURI, _ := config["redirectUri"].(string)
|
||||
|
||||
if authURL == "" || clientID == "" || redirectURI == "" {
|
||||
return r.jsError("authUrl, clientId, and redirectUri are required")
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "authUrl, clientId, and redirectUri are required",
|
||||
})
|
||||
}
|
||||
if err := validateExtensionAuthURL(authURL); err != nil {
|
||||
return r.jsError("%s", err.Error())
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
scope, _ := config["scope"].(string)
|
||||
extraParams, _ := config["extraParams"].(map[string]any)
|
||||
extraParams, _ := config["extraParams"].(map[string]interface{})
|
||||
|
||||
verifier, err := generatePKCEVerifier(64)
|
||||
if err != nil {
|
||||
return r.jsError("failed to generate PKCE: %v", err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to generate PKCE: %v", err),
|
||||
})
|
||||
}
|
||||
challenge := generatePKCEChallenge(verifier)
|
||||
|
||||
@@ -319,7 +340,10 @@ func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
|
||||
parsedURL, err := url.Parse(authURL)
|
||||
if err != nil {
|
||||
return r.jsError("invalid authUrl: %v", err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("invalid authUrl: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
query := parsedURL.Query()
|
||||
@@ -345,15 +369,15 @@ func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
ExtensionID: r.extensionID,
|
||||
AuthURL: fullAuthURL,
|
||||
CallbackURL: redirectURI,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
|
||||
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, summarizeURLForLog(fullAuthURL))
|
||||
|
||||
return r.jsSuccess(map[string]any{
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"authUrl": fullAuthURL,
|
||||
"pkce": map[string]any{
|
||||
"pkce": map[string]interface{}{
|
||||
"verifier": verifier,
|
||||
"challenge": challenge,
|
||||
"method": "S256",
|
||||
@@ -363,13 +387,19 @@ func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
|
||||
func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.jsError("config object is required")
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config object is required",
|
||||
})
|
||||
}
|
||||
|
||||
configObj := call.Arguments[0].Export()
|
||||
config, ok := configObj.(map[string]any)
|
||||
config, ok := configObj.(map[string]interface{})
|
||||
if !ok {
|
||||
return r.jsError("config must be an object")
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "config must be an object",
|
||||
})
|
||||
}
|
||||
|
||||
tokenURL, _ := config["tokenUrl"].(string)
|
||||
@@ -378,7 +408,10 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
code, _ := config["code"].(string)
|
||||
|
||||
if tokenURL == "" || clientID == "" || code == "" {
|
||||
return r.jsError("tokenUrl, clientId, and code are required")
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "tokenUrl, clientId, and code are required",
|
||||
})
|
||||
}
|
||||
|
||||
extensionAuthStateMu.RLock()
|
||||
@@ -390,11 +423,17 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
extensionAuthStateMu.RUnlock()
|
||||
|
||||
if verifier == "" {
|
||||
return r.jsError("no PKCE verifier found - call generatePKCE or startOAuthWithPKCE first")
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "no PKCE verifier found - call generatePKCE or startOAuthWithPKCE first",
|
||||
})
|
||||
}
|
||||
|
||||
if err := r.validateDomain(tokenURL); err != nil {
|
||||
return r.jsError("%s", err.Error())
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
formData := url.Values{}
|
||||
@@ -406,7 +445,7 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
formData.Set("redirect_uri", redirectURI)
|
||||
}
|
||||
|
||||
if extraParams, ok := config["extraParams"].(map[string]any); ok {
|
||||
if extraParams, ok := config["extraParams"].(map[string]interface{}); ok {
|
||||
for k, v := range extraParams {
|
||||
formData.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
@@ -414,7 +453,10 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
|
||||
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(formData.Encode()))
|
||||
if err != nil {
|
||||
return r.jsError("%s", err.Error())
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
@@ -423,22 +465,28 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.jsError("%s", err.Error())
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return r.jsError("%s", err.Error())
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
bodyPreview := sanitizeSensitiveLogText(string(body))
|
||||
if len(bodyPreview) > 1000 {
|
||||
bodyPreview = bodyPreview[:1000] + "...[truncated]"
|
||||
}
|
||||
|
||||
var tokenResp map[string]any
|
||||
var tokenResp map[string]interface{}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return r.vm.ToValue(map[string]any{
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to parse token response: %v", err),
|
||||
"body": bodyPreview,
|
||||
@@ -447,7 +495,7 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
|
||||
if errMsg, ok := tokenResp["error"].(string); ok {
|
||||
errDesc, _ := tokenResp["error_description"].(string)
|
||||
return r.vm.ToValue(map[string]any{
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": errMsg,
|
||||
"error_description": errDesc,
|
||||
@@ -459,7 +507,7 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
expiresIn, _ := tokenResp["expires_in"].(float64)
|
||||
|
||||
if accessToken == "" {
|
||||
return r.vm.ToValue(map[string]any{
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "no access_token in response",
|
||||
"body": bodyPreview,
|
||||
@@ -484,7 +532,7 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
|
||||
GoLog("[Extension:%s] PKCE token exchange successful\n", r.extensionID)
|
||||
|
||||
result := map[string]any{
|
||||
result := map[string]interface{}{
|
||||
"success": true,
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
|
||||
@@ -23,7 +23,7 @@ type runtimeBlockCipherOptions struct {
|
||||
Padding string
|
||||
}
|
||||
|
||||
func parseRuntimeOptionsArgument(call goja.FunctionCall, index int) map[string]any {
|
||||
func parseRuntimeOptionsArgument(call goja.FunctionCall, index int) map[string]interface{} {
|
||||
if len(call.Arguments) <= index {
|
||||
return nil
|
||||
}
|
||||
@@ -34,13 +34,13 @@ func parseRuntimeOptionsArgument(call goja.FunctionCall, index int) map[string]a
|
||||
}
|
||||
|
||||
exported := value.Export()
|
||||
if options, ok := exported.(map[string]any); ok {
|
||||
if options, ok := exported.(map[string]interface{}); ok {
|
||||
return options
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runtimeOptionString(options map[string]any, key, defaultValue string) string {
|
||||
func runtimeOptionString(options map[string]interface{}, key, defaultValue string) string {
|
||||
if options == nil {
|
||||
return defaultValue
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func runtimeOptionString(options map[string]any, key, defaultValue string) strin
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func runtimeOptionBool(options map[string]any, key string, defaultValue bool) bool {
|
||||
func runtimeOptionBool(options map[string]interface{}, key string, defaultValue bool) bool {
|
||||
if options == nil {
|
||||
return defaultValue
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func runtimeOptionBool(options map[string]any, key string, defaultValue bool) bo
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func runtimeOptionInt64(options map[string]any, key string, defaultValue int64) int64 {
|
||||
func runtimeOptionInt64(options map[string]interface{}, key string, defaultValue int64) int64 {
|
||||
if options == nil {
|
||||
return defaultValue
|
||||
}
|
||||
@@ -121,7 +121,7 @@ func runtimeOptionInt64(options map[string]any, key string, defaultValue int64)
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func runtimeOptionHasKey(options map[string]any, key string) bool {
|
||||
func runtimeOptionHasKey(options map[string]interface{}, key string) bool {
|
||||
if options == nil {
|
||||
return false
|
||||
}
|
||||
@@ -150,7 +150,7 @@ func decodeRuntimeBytesString(input, encoding string) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func decodeRuntimeBytesValue(raw any, encoding string) ([]byte, error) {
|
||||
func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
return decodeRuntimeBytesString(value, encoding)
|
||||
@@ -163,7 +163,7 @@ func decodeRuntimeBytesValue(raw any, encoding string) ([]byte, error) {
|
||||
cloned := make([]byte, len(src))
|
||||
copy(cloned, src)
|
||||
return cloned, nil
|
||||
case []any:
|
||||
case []interface{}:
|
||||
decoded := make([]byte, len(value))
|
||||
for i, item := range value {
|
||||
switch num := item.(type) {
|
||||
@@ -196,7 +196,7 @@ func encodeRuntimeBytes(data []byte, encoding string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func parseRuntimeBlockCipherOptions(options map[string]any) (*runtimeBlockCipherOptions, error) {
|
||||
func parseRuntimeBlockCipherOptions(options map[string]interface{}) (*runtimeBlockCipherOptions, error) {
|
||||
parsed := &runtimeBlockCipherOptions{
|
||||
Algorithm: strings.ToLower(runtimeOptionString(options, "algorithm", "")),
|
||||
Mode: strings.ToLower(runtimeOptionString(options, "mode", "cbc")),
|
||||
@@ -270,28 +270,44 @@ func removePKCS7Padding(data []byte, blockSize int) ([]byte, error) {
|
||||
|
||||
func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt bool) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.jsError("data and options are required")
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "data and options are required",
|
||||
})
|
||||
}
|
||||
|
||||
options := parseRuntimeOptionsArgument(call, 1)
|
||||
parsedOptions, err := parseRuntimeBlockCipherOptions(options)
|
||||
if err != nil {
|
||||
return r.jsError("%s", err.Error())
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
switch parsedOptions.Mode {
|
||||
case "cbc", "ctr":
|
||||
// supported
|
||||
default:
|
||||
return r.jsError("unsupported block cipher mode: %s", parsedOptions.Mode)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
|
||||
})
|
||||
}
|
||||
|
||||
inputData, err := decodeRuntimeBytesValue(call.Arguments[0].Export(), parsedOptions.InputEncoding)
|
||||
if err != nil {
|
||||
return r.jsError("%s", err.Error())
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
block, err := newRuntimeBlockCipher(parsedOptions)
|
||||
if err != nil {
|
||||
return r.jsError("%s", err.Error())
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if len(parsedOptions.IV) != block.BlockSize() {
|
||||
@@ -299,7 +315,10 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
|
||||
if parsedOptions.Mode == "ctr" {
|
||||
ivLabel = "iv (counter)"
|
||||
}
|
||||
return r.jsError("%s must be %d bytes for %s", ivLabel, block.BlockSize(), parsedOptions.Algorithm)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("%s must be %d bytes for %s", ivLabel, block.BlockSize(), parsedOptions.Algorithm),
|
||||
})
|
||||
}
|
||||
|
||||
var output []byte
|
||||
@@ -314,7 +333,10 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
|
||||
data = applyPKCS7Padding(data, block.BlockSize())
|
||||
}
|
||||
if len(data)%block.BlockSize() != 0 {
|
||||
return r.jsError("input length must be a multiple of %d bytes", block.BlockSize())
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
|
||||
})
|
||||
}
|
||||
|
||||
output = make([]byte, len(data))
|
||||
@@ -323,7 +345,10 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
|
||||
if parsedOptions.Padding == "pkcs7" {
|
||||
output, err = removePKCS7Padding(output, block.BlockSize())
|
||||
if err != nil {
|
||||
return r.jsError("%s", err.Error())
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -333,10 +358,14 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
|
||||
|
||||
encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding)
|
||||
if err != nil {
|
||||
return r.jsError("%s", err.Error())
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.jsSuccess(map[string]any{
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": encoded,
|
||||
"block_size": block.BlockSize(),
|
||||
})
|
||||
@@ -364,20 +393,21 @@ func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value
|
||||
// dominant cost under the goja interpreter.
|
||||
//
|
||||
// JS signature:
|
||||
//
|
||||
// utils.decryptCTRSegments(data, {
|
||||
// algorithm: "aes", // optional, default "aes"
|
||||
// key: "<hex>", keyEncoding: "hex",
|
||||
// segments: [ { offset: <int>, size: <int>, iv: "<base64>" }, ... ],
|
||||
// ivEncoding: "base64", // encoding of each segment.iv, default base64
|
||||
// inputEncoding: "bytes", // "bytes" for ArrayBuffer/Uint8Array, else base64/hex
|
||||
// outputEncoding: "bytes" // "bytes" -> ArrayBuffer; else base64/hex string
|
||||
// })
|
||||
//
|
||||
// utils.decryptCTRSegments(data, {
|
||||
// algorithm: "aes", // optional, default "aes"
|
||||
// key: "<hex>", keyEncoding: "hex",
|
||||
// segments: [ { offset: <int>, size: <int>, iv: "<base64>" }, ... ],
|
||||
// ivEncoding: "base64", // encoding of each segment.iv, default base64
|
||||
// inputEncoding: "bytes", // "bytes" for ArrayBuffer/Uint8Array, else base64/hex
|
||||
// outputEncoding: "bytes" // "bytes" -> ArrayBuffer; else base64/hex string
|
||||
// })
|
||||
// Returns { success, data, segments_processed } or { success:false, error }.
|
||||
func (r *extensionRuntime) decryptCTRSegments(call goja.FunctionCall) goja.Value {
|
||||
fail := func(msg string) goja.Value {
|
||||
return r.jsError("%s", msg)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": msg,
|
||||
})
|
||||
}
|
||||
|
||||
if len(call.Arguments) < 2 {
|
||||
@@ -438,14 +468,14 @@ func (r *extensionRuntime) decryptCTRSegments(call goja.FunctionCall) goja.Value
|
||||
if !ok || rawSegments == nil {
|
||||
return fail("segments array is required")
|
||||
}
|
||||
segments, ok := rawSegments.([]any)
|
||||
segments, ok := rawSegments.([]interface{})
|
||||
if !ok {
|
||||
return fail("segments must be an array")
|
||||
}
|
||||
|
||||
processed := 0
|
||||
for i, rawSeg := range segments {
|
||||
seg, ok := rawSeg.(map[string]any)
|
||||
seg, ok := rawSeg.(map[string]interface{})
|
||||
if !ok {
|
||||
return fail(fmt.Sprintf("segment %d is not an object", i))
|
||||
}
|
||||
@@ -485,7 +515,8 @@ func (r *extensionRuntime) decryptCTRSegments(call goja.FunctionCall) goja.Value
|
||||
// Return raw bytes as an ArrayBuffer when requested (zero-copy-ish, no
|
||||
// base64). Otherwise fall back to an encoded string.
|
||||
if outputEncoding == "bytes" || outputEncoding == "raw" {
|
||||
return r.jsSuccess(map[string]any{
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": r.vm.NewArrayBuffer(data),
|
||||
"segments_processed": processed,
|
||||
})
|
||||
@@ -496,7 +527,8 @@ func (r *extensionRuntime) decryptCTRSegments(call goja.FunctionCall) goja.Value
|
||||
return fail(err.Error())
|
||||
}
|
||||
|
||||
return r.jsSuccess(map[string]any{
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": encoded,
|
||||
"segments_processed": processed,
|
||||
})
|
||||
|
||||
@@ -51,17 +51,14 @@ func ClearFFmpegCommand(commandID string) {
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
if r.manifest == nil || !r.manifest.Permissions.File || !r.manifest.HasCapability("rawFfmpeg") {
|
||||
return r.jsError("raw FFmpeg execution permission denied")
|
||||
}
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.jsError("command is required")
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "command is required",
|
||||
})
|
||||
}
|
||||
|
||||
return r.executeFFmpegCommand(call.Arguments[0].String(), "", "")
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) executeFFmpegCommand(command, inputPath, outputPath string) goja.Value {
|
||||
command := call.Arguments[0].String()
|
||||
|
||||
ffmpegCommandsMu.Lock()
|
||||
ffmpegCommandID++
|
||||
@@ -69,8 +66,6 @@ func (r *extensionRuntime) executeFFmpegCommand(command, inputPath, outputPath s
|
||||
ffmpegCommands[cmdID] = &FFmpegCommand{
|
||||
ExtensionID: r.extensionID,
|
||||
Command: command,
|
||||
InputPath: inputPath,
|
||||
OutputPath: outputPath,
|
||||
Completed: false,
|
||||
}
|
||||
ffmpegCommandsMu.Unlock()
|
||||
@@ -87,7 +82,7 @@ func (r *extensionRuntime) executeFFmpegCommand(command, inputPath, outputPath s
|
||||
|
||||
if completed {
|
||||
ffmpegCommandsMu.RLock()
|
||||
result := map[string]any{
|
||||
result := map[string]interface{}{
|
||||
"success": cmd.Success,
|
||||
"output": cmd.Output,
|
||||
}
|
||||
@@ -102,7 +97,10 @@ func (r *extensionRuntime) executeFFmpegCommand(command, inputPath, outputPath s
|
||||
|
||||
if time.Since(start) > timeout {
|
||||
ClearFFmpegCommand(cmdID)
|
||||
return r.jsError("FFmpeg command timed out")
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "FFmpeg command timed out",
|
||||
})
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
@@ -110,24 +108,25 @@ func (r *extensionRuntime) executeFFmpegCommand(command, inputPath, outputPath s
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
if r.manifest == nil || !r.manifest.Permissions.File {
|
||||
return r.jsError("file permission denied")
|
||||
}
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.jsError("file path is required")
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "file path is required",
|
||||
})
|
||||
}
|
||||
|
||||
filePath, err := r.validatePath(call.Arguments[0].String())
|
||||
if err != nil {
|
||||
return r.jsError("%s", err.Error())
|
||||
}
|
||||
filePath := call.Arguments[0].String()
|
||||
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err != nil {
|
||||
return r.jsError("%s", err.Error())
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.jsSuccess(map[string]any{
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"bit_depth": quality.BitDepth,
|
||||
"sample_rate": quality.SampleRate,
|
||||
"total_samples": quality.TotalSamples,
|
||||
@@ -137,25 +136,19 @@ func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
if r.manifest == nil || !r.manifest.Permissions.File {
|
||||
return r.jsError("file permission denied")
|
||||
}
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.jsError("input and output paths are required")
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "input and output paths are required",
|
||||
})
|
||||
}
|
||||
|
||||
inputPath, err := r.validatePath(call.Arguments[0].String())
|
||||
if err != nil {
|
||||
return r.jsError("invalid input path: %v", err)
|
||||
}
|
||||
outputPath, err := r.validatePath(call.Arguments[1].String())
|
||||
if err != nil {
|
||||
return r.jsError("invalid output path: %v", err)
|
||||
}
|
||||
inputPath := call.Arguments[0].String()
|
||||
outputPath := call.Arguments[1].String()
|
||||
|
||||
options := map[string]any{}
|
||||
options := map[string]interface{}{}
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
if opts, ok := call.Arguments[2].Export().(map[string]any); ok {
|
||||
if opts, ok := call.Arguments[2].Export().(map[string]interface{}); ok {
|
||||
options = opts
|
||||
}
|
||||
}
|
||||
@@ -183,5 +176,8 @@ func (r *extensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
|
||||
command := strings.Join(cmdParts, " ")
|
||||
|
||||
return r.executeFFmpegCommand(command, inputPath, outputPath)
|
||||
execCall := goja.FunctionCall{
|
||||
Arguments: []goja.Value{r.vm.ToValue(command)},
|
||||
}
|
||||
return r.ffmpegExecute(execCall)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,56 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func TestExtensionFileReadLengthRequiresChunking(t *testing.T) {
|
||||
if _, err := extensionFileReadLength(maxExtensionFileReadBytes+1, 0, -1); err == nil {
|
||||
t.Fatal("unbounded large read was accepted")
|
||||
}
|
||||
if _, err := extensionFileReadLength(maxExtensionFileReadBytes+1, 0, maxExtensionFileReadBytes+1); err == nil {
|
||||
t.Fatal("oversized explicit read was accepted")
|
||||
}
|
||||
if got, err := extensionFileReadLength(maxExtensionFileReadBytes+1, maxExtensionFileReadBytes, -1); err != nil || got != 1 {
|
||||
t.Fatalf("tail read length = %d, %v", got, err)
|
||||
}
|
||||
if got, err := extensionFileReadLength(5, 2, 10); err != nil || got != 3 {
|
||||
t.Fatalf("clamped read length = %d, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionFileAPIsRejectUnboundedLargeReads(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "large.bin")
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Truncate(maxExtensionFileReadBytes + 1); err != nil {
|
||||
file.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
file.Close()
|
||||
|
||||
vm := goja.New()
|
||||
runtime := &extensionRuntime{
|
||||
vm: vm,
|
||||
dataDir: dir,
|
||||
manifest: &ExtensionManifest{Permissions: ExtensionPermissions{File: true}},
|
||||
}
|
||||
|
||||
readResult := runtime.fileRead(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("large.bin")}}).Export().(map[string]any)
|
||||
if readResult["success"] != false || !strings.Contains(readResult["error"].(string), "chunks") {
|
||||
t.Fatalf("fileRead result = %#v", readResult)
|
||||
}
|
||||
|
||||
readBytesResult := runtime.fileReadBytes(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("large.bin")}}).Export().(map[string]any)
|
||||
if readBytesResult["success"] != false || !strings.Contains(readBytesResult["error"].(string), "chunks") {
|
||||
t.Fatalf("fileReadBytes result = %#v", readBytesResult)
|
||||
}
|
||||
}
|
||||
@@ -35,12 +35,6 @@ func readExtensionHTTPResponseBody(resp *http.Response) ([]byte, error) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func setDefaultExtensionUA(req *http.Request) {
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) validateDomain(urlStr string) error {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
@@ -74,88 +68,35 @@ func (r *extensionRuntime) validateDomain(urlStr string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseGojaHeaders converts an exported goja value (expected map[string]any)
|
||||
// into string headers. Non-map values yield an empty map.
|
||||
func parseGojaHeaders(v any) map[string]string {
|
||||
headers := make(map[string]string)
|
||||
if h, ok := v.(map[string]any); ok {
|
||||
for k, val := range h {
|
||||
headers[k] = fmt.Sprintf("%v", val)
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
// coerceExportedBody stringifies a request body already exported from goja:
|
||||
// strings pass through, maps/arrays are JSON-encoded, anything else is %v.
|
||||
func coerceExportedBody(v any) (string, error) {
|
||||
switch b := v.(type) {
|
||||
case string:
|
||||
return b, nil
|
||||
case map[string]any, []any:
|
||||
jsonBytes, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to stringify body: %v", err)
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
default:
|
||||
return fmt.Sprintf("%v", b), nil
|
||||
}
|
||||
}
|
||||
|
||||
// coerceGojaBody is coerceExportedBody for a raw goja argument; undefined/null
|
||||
// yield "", and the fallback uses goja's own String() conversion.
|
||||
func coerceGojaBody(v goja.Value) (string, error) {
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
return "", nil
|
||||
}
|
||||
switch b := v.Export().(type) {
|
||||
case string:
|
||||
return b, nil
|
||||
case map[string]any, []any:
|
||||
return coerceExportedBody(b)
|
||||
default:
|
||||
return v.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
func flattenHTTPHeaders(h http.Header) map[string]any {
|
||||
flat := make(map[string]any, len(h))
|
||||
for k, v := range h {
|
||||
if len(v) == 1 {
|
||||
flat[k] = v[0]
|
||||
} else {
|
||||
flat[k] = v
|
||||
}
|
||||
}
|
||||
return flat
|
||||
}
|
||||
|
||||
// checkExtensionURL extracts and allowlist-validates the URL argument.
|
||||
// On failure it returns a non-nil error value to hand back to JS.
|
||||
func (r *extensionRuntime) checkExtensionURL(call goja.FunctionCall) (string, goja.Value) {
|
||||
func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return "", r.vm.ToValue(map[string]any{
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return "", r.vm.ToValue(map[string]any{
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return urlStr, nil
|
||||
}
|
||||
|
||||
// doExtensionHTTP builds and executes the request, returning the extension
|
||||
// response map (or {"error": ...}). defaultJSON sets Content-Type
|
||||
// application/json when the caller did not provide one.
|
||||
func (r *extensionRuntime) doExtensionHTTP(method, urlStr string, body io.Reader, defaultJSON bool, headers map[string]string) goja.Value {
|
||||
req, err := http.NewRequest(method, urlStr, body)
|
||||
headers := make(map[string]string)
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
headersObj := call.Arguments[1].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]any{
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
@@ -164,91 +105,251 @@ func (r *extensionRuntime) doExtensionHTTP(method, urlStr string, body io.Reader
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
setDefaultExtensionUA(req)
|
||||
if defaultJSON && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]any{
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := readExtensionHTTPResponseBody(resp)
|
||||
body, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]any{
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]any{
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"url": resp.Request.URL.String(),
|
||||
"body": string(respBody),
|
||||
"headers": flattenHTTPHeaders(resp.Header),
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
urlStr, errVal := r.checkExtensionURL(call)
|
||||
if errVal != nil {
|
||||
return errVal
|
||||
}
|
||||
headers := parseGojaHeaders(call.Argument(1).Export())
|
||||
return r.doExtensionHTTP("GET", urlStr, nil, false, headers)
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
urlStr, errVal := r.checkExtensionURL(call)
|
||||
if errVal != nil {
|
||||
return errVal
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
bodyStr, err := coerceGojaBody(call.Argument(1))
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]any{
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
headers := parseGojaHeaders(call.Argument(2).Export())
|
||||
// POST always sends a (possibly empty) body and defaults Content-Type.
|
||||
return r.doExtensionHTTP("POST", urlStr, strings.NewReader(bodyStr), true, headers)
|
||||
|
||||
var bodyStr string
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
bodyArg := call.Arguments[1].Export()
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||
})
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = call.Arguments[1].String()
|
||||
}
|
||||
}
|
||||
|
||||
headers := make(map[string]string)
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
headersObj := call.Arguments[2].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", urlStr, strings.NewReader(bodyStr))
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
if req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"url": resp.Request.URL.String(),
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
urlStr, errVal := r.checkExtensionURL(call)
|
||||
if errVal != nil {
|
||||
return errVal
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
method := "GET"
|
||||
var bodyStr string
|
||||
var headers map[string]string
|
||||
headers := make(map[string]string)
|
||||
|
||||
if opts, ok := call.Argument(1).Export().(map[string]any); ok {
|
||||
if m, ok := opts["method"].(string); ok {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||
var err error
|
||||
if bodyStr, err = coerceExportedBody(bodyArg); err != nil {
|
||||
return r.vm.ToValue(map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
optionsObj := call.Arguments[1].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
if m, ok := opts["method"].(string); ok {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
|
||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||
})
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
if h, ok := opts["headers"].(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
headers = parseGojaHeaders(opts["headers"])
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
}
|
||||
return r.doExtensionHTTP(method, urlStr, reqBody, bodyStr != "", headers)
|
||||
|
||||
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"url": resp.Request.URL.String(),
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||
@@ -264,35 +365,124 @@ func (r *extensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||
urlStr, errVal := r.checkExtensionURL(call)
|
||||
if errVal != nil {
|
||||
return errVal
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
})
|
||||
}
|
||||
|
||||
// DELETE takes (url, headers); other methods take (url, body, headers).
|
||||
urlStr := call.Arguments[0].String()
|
||||
|
||||
if err := r.validateDomain(urlStr); err != nil {
|
||||
GoLog("[Extension:%s] HTTP blocked: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
var bodyStr string
|
||||
headerArg := 1
|
||||
if method != "DELETE" {
|
||||
var err error
|
||||
if bodyStr, err = coerceGojaBody(call.Argument(1)); err != nil {
|
||||
return r.vm.ToValue(map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
headers := make(map[string]string)
|
||||
|
||||
if method == "DELETE" {
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
headersObj := call.Arguments[1].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
headerArg = 2
|
||||
} else {
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
bodyArg := call.Arguments[1].Export()
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": fmt.Sprintf("failed to stringify body: %v", err),
|
||||
})
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = call.Arguments[1].String()
|
||||
}
|
||||
}
|
||||
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
headersObj := call.Arguments[2].Export()
|
||||
if h, ok := headersObj.(map[string]interface{}); ok {
|
||||
for k, v := range h {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
headers := parseGojaHeaders(call.Argument(headerArg).Export())
|
||||
|
||||
var reqBody io.Reader
|
||||
if bodyStr != "" {
|
||||
reqBody = strings.NewReader(bodyStr)
|
||||
}
|
||||
return r.doExtensionHTTP(method, urlStr, reqBody, bodyStr != "", headers)
|
||||
|
||||
req, err := http.NewRequest(method, urlStr, reqBody)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "Spotiflac-Extension/1.0")
|
||||
}
|
||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"url": resp.Request.URL.String(),
|
||||
"body": string(body),
|
||||
"headers": respHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
||||
jar.Clear()
|
||||
jar.mu.Lock()
|
||||
jar.cookies = make(map[string][]*http.Cookie)
|
||||
jar.mu.Unlock()
|
||||
GoLog("[Extension:%s] Cookies cleared\n", r.extensionID)
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
@@ -25,19 +25,39 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
|
||||
method := "GET"
|
||||
var bodyStr string
|
||||
var headers map[string]string
|
||||
headers := make(map[string]string)
|
||||
|
||||
if opts, ok := call.Argument(1).Export().(map[string]any); ok {
|
||||
if m, ok := opts["method"].(string); ok {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||
var err error
|
||||
if bodyStr, err = coerceExportedBody(bodyArg); err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
optionsObj := call.Arguments[1].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
if m, ok := opts["method"].(string); ok {
|
||||
method = strings.ToUpper(m)
|
||||
}
|
||||
|
||||
if bodyArg, ok := opts["body"]; ok && bodyArg != nil {
|
||||
switch v := bodyArg.(type) {
|
||||
case string:
|
||||
bodyStr = v
|
||||
case map[string]interface{}, []interface{}:
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.createFetchError(fmt.Sprintf("failed to stringify body: %v", err))
|
||||
}
|
||||
bodyStr = string(jsonBytes)
|
||||
default:
|
||||
bodyStr = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
if h, ok := opts["headers"]; ok && h != nil {
|
||||
switch hv := h.(type) {
|
||||
case map[string]interface{}:
|
||||
for k, v := range hv {
|
||||
headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
headers = parseGojaHeaders(opts["headers"])
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
@@ -72,7 +92,14 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
|
||||
respHeaders := flattenHTTPHeaders(resp.Header)
|
||||
respHeaders := make(map[string]interface{})
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
respHeaders[k] = v[0]
|
||||
} else {
|
||||
respHeaders[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
responseObj := r.vm.NewObject()
|
||||
responseObj.Set("ok", resp.StatusCode >= 200 && resp.StatusCode < 300)
|
||||
@@ -88,7 +115,7 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
|
||||
responseObj.Set("json", func(call goja.FunctionCall) goja.Value {
|
||||
var result any
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
GoLog("[Extension:%s] fetch json() parse error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
@@ -97,7 +124,7 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
|
||||
responseObj.Set("arrayBuffer", func(call goja.FunctionCall) goja.Value {
|
||||
byteArray := make([]any, len(body))
|
||||
byteArray := make([]interface{}, len(body))
|
||||
for i, b := range body {
|
||||
byteArray[i] = int(b)
|
||||
}
|
||||
@@ -158,7 +185,7 @@ func (r *extensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
input := call.Arguments[0].String()
|
||||
bytes := []byte(input)
|
||||
|
||||
result := make([]any, len(bytes))
|
||||
result := make([]interface{}, len(bytes))
|
||||
for i, b := range bytes {
|
||||
result[i] = int(b)
|
||||
}
|
||||
@@ -167,10 +194,10 @@ func (r *extensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
|
||||
encoder.Set("encodeInto", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return vm.ToValue(map[string]any{"read": 0, "written": 0})
|
||||
return vm.ToValue(map[string]interface{}{"read": 0, "written": 0})
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
return vm.ToValue(map[string]any{
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"read": len(input),
|
||||
"written": len([]byte(input)),
|
||||
})
|
||||
@@ -201,7 +228,7 @@ func (r *extensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
switch v := input.(type) {
|
||||
case []byte:
|
||||
bytes = v
|
||||
case []any:
|
||||
case []interface{}:
|
||||
bytes = make([]byte, len(v))
|
||||
for i, val := range v {
|
||||
switch n := val.(type) {
|
||||
@@ -330,7 +357,7 @@ func (r *extensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
case string:
|
||||
parsed, _ := url.ParseQuery(strings.TrimPrefix(v, "?"))
|
||||
values = parsed
|
||||
case map[string]any:
|
||||
case map[string]interface{}:
|
||||
for k, val := range v {
|
||||
values.Set(k, fmt.Sprintf("%v", val))
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestStallWatchdogCancelsOnNoData verifies the watchdog aborts a transfer that
|
||||
// stops sending bytes, marks itself stalled (not user-cancelled), and does so
|
||||
// after resetting on the initial byte.
|
||||
func TestStallWatchdogCancelsOnNoData(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", "1000")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("x"))
|
||||
w.(http.Flusher).Flush()
|
||||
<-block // simulate a dead radio mid-transfer: stop sending
|
||||
}))
|
||||
defer srv.Close()
|
||||
defer close(block)
|
||||
|
||||
req, _ := http.NewRequestWithContext(context.Background(), "GET", srv.URL, nil)
|
||||
req, wd := bindStallWatchdog(req, 150*time.Millisecond)
|
||||
defer wd.stop()
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
buf := make([]byte, 32)
|
||||
var readErr error
|
||||
for {
|
||||
n, er := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
wd.reset()
|
||||
}
|
||||
if er != nil {
|
||||
readErr = er
|
||||
break
|
||||
}
|
||||
}
|
||||
if readErr == nil {
|
||||
t.Fatal("expected read error from stall cancel")
|
||||
}
|
||||
if !wd.stalled.Load() {
|
||||
t.Fatalf("watchdog did not mark stalled; err=%v", readErr)
|
||||
}
|
||||
}
|
||||
@@ -10,106 +10,161 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// Isolated per-download runtimes of the same extension share the storage,
|
||||
// credentials, and salt files on disk, so writers must be serialized
|
||||
// process-wide; the per-runtime mutexes only cover a single VM.
|
||||
var extensionFileMus sync.Map // file path -> *sync.Mutex
|
||||
|
||||
func extensionFileMu(path string) *sync.Mutex {
|
||||
mu, _ := extensionFileMus.LoadOrStore(path, &sync.Mutex{})
|
||||
return mu.(*sync.Mutex)
|
||||
}
|
||||
|
||||
// writeExtensionFileLocked writes data via a temp file + rename so a reader
|
||||
// never observes a torn write. Callers must hold extensionFileMu(path).
|
||||
func writeExtensionFileLocked(path string, data []byte) error {
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
const (
|
||||
defaultStorageFlushDelay = 400 * time.Millisecond
|
||||
storageFlushRetryDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
func (r *extensionRuntime) getStoragePath() string {
|
||||
return filepath.Join(r.dataDir, "storage.json")
|
||||
}
|
||||
|
||||
func readJSONMapFile(path string) (map[string]any, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]any), nil
|
||||
}
|
||||
return nil, err
|
||||
func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} {
|
||||
if len(src) == 0 {
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
result := make(map[string]any)
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, err
|
||||
dst := make(map[string]interface{}, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
if result == nil {
|
||||
result = make(map[string]any)
|
||||
}
|
||||
return result, nil
|
||||
return dst
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) refreshStorage() error {
|
||||
path := r.getStoragePath()
|
||||
fileMu := extensionFileMu(path)
|
||||
fileMu.Lock()
|
||||
snapshot, err := readJSONMapFile(path)
|
||||
fileMu.Unlock()
|
||||
func (r *extensionRuntime) ensureStorageLoaded() error {
|
||||
r.storageMu.RLock()
|
||||
if r.storageLoaded {
|
||||
r.storageMu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
r.storageMu.RUnlock()
|
||||
|
||||
r.storageMu.Lock()
|
||||
defer r.storageMu.Unlock()
|
||||
if r.storageLoaded {
|
||||
return nil
|
||||
}
|
||||
|
||||
storagePath := r.getStoragePath()
|
||||
data, err := os.ReadFile(storagePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
r.storageCache = make(map[string]interface{})
|
||||
r.storageLoaded = true
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
r.storageMu.Lock()
|
||||
r.storageCache = snapshot
|
||||
r.storageMu.Unlock()
|
||||
|
||||
var storage map[string]interface{}
|
||||
if err := json.Unmarshal(data, &storage); err != nil {
|
||||
return err
|
||||
}
|
||||
if storage == nil {
|
||||
storage = make(map[string]interface{})
|
||||
}
|
||||
|
||||
r.storageCache = storage
|
||||
r.storageLoaded = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) mutateStorage(mutate func(map[string]any) bool) error {
|
||||
r.storageMu.RLock()
|
||||
closed := r.storageClosed
|
||||
r.storageMu.RUnlock()
|
||||
if closed {
|
||||
return fmt.Errorf("storage is closed")
|
||||
func (r *extensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||
if err := r.ensureStorageLoaded(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
path := r.getStoragePath()
|
||||
fileMu := extensionFileMu(path)
|
||||
fileMu.Lock()
|
||||
snapshot, err := readJSONMapFile(path)
|
||||
if err == nil && mutate(snapshot) {
|
||||
var data []byte
|
||||
data, err = json.Marshal(snapshot)
|
||||
if err == nil {
|
||||
err = writeExtensionFileLocked(path, data)
|
||||
}
|
||||
r.storageMu.RLock()
|
||||
defer r.storageMu.RUnlock()
|
||||
return cloneInterfaceMap(r.storageCache), nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
||||
if r.storageClosed {
|
||||
return
|
||||
}
|
||||
fileMu.Unlock()
|
||||
if r.storageTimer != nil {
|
||||
return
|
||||
}
|
||||
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
|
||||
data, err := json.Marshal(storage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.storageWriteMu.Lock()
|
||||
defer r.storageWriteMu.Unlock()
|
||||
|
||||
return os.WriteFile(r.getStoragePath(), data, 0600)
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) flushStorageDirtyAsync() {
|
||||
if err := r.flushStorageDirty(); err != nil {
|
||||
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) flushStorageDirty() error {
|
||||
r.storageMu.Lock()
|
||||
r.storageCache = snapshot
|
||||
if r.storageClosed {
|
||||
r.storageTimer = nil
|
||||
r.storageMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if !r.storageDirty {
|
||||
r.storageTimer = nil
|
||||
r.storageMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
snapshot := cloneInterfaceMap(r.storageCache)
|
||||
r.storageDirty = false
|
||||
r.storageTimer = nil
|
||||
r.storageMu.Unlock()
|
||||
|
||||
if err := r.persistStorageSnapshot(snapshot); err != nil {
|
||||
r.storageMu.Lock()
|
||||
r.storageDirty = true
|
||||
r.queueStorageFlushLocked(storageFlushRetryDelay)
|
||||
r.storageMu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) flushStorageNow() error {
|
||||
// Mutations are persisted synchronously under the process-wide file lock.
|
||||
return nil
|
||||
r.storageMu.Lock()
|
||||
if r.storageTimer != nil {
|
||||
r.storageTimer.Stop()
|
||||
r.storageTimer = nil
|
||||
}
|
||||
if !r.storageLoaded || r.storageClosed {
|
||||
r.storageMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
snapshot := cloneInterfaceMap(r.storageCache)
|
||||
r.storageDirty = false
|
||||
r.storageMu.Unlock()
|
||||
|
||||
return r.persistStorageSnapshot(snapshot)
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) closeStorageFlusher() {
|
||||
r.storageMu.Lock()
|
||||
r.storageClosed = true
|
||||
r.storageDirty = false
|
||||
if r.storageTimer != nil {
|
||||
r.storageTimer.Stop()
|
||||
r.storageTimer = nil
|
||||
}
|
||||
r.storageMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -120,7 +175,7 @@ func (r *extensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
if err := r.refreshStorage(); err != nil {
|
||||
if err := r.ensureStorageLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
@@ -146,14 +201,27 @@ func (r *extensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||
key := call.Arguments[0].String()
|
||||
value := call.Arguments[1].Export()
|
||||
|
||||
if err := r.mutateStorage(func(storage map[string]any) bool {
|
||||
storage[key] = value
|
||||
return true
|
||||
}); err != nil {
|
||||
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
|
||||
if err := r.ensureStorageLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
r.storageMu.Lock()
|
||||
if r.storageClosed {
|
||||
r.storageMu.Unlock()
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
if existing, exists := r.storageCache[key]; exists {
|
||||
if reflect.DeepEqual(existing, value) {
|
||||
r.storageMu.Unlock()
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
}
|
||||
r.storageCache[key] = value
|
||||
r.storageDirty = true
|
||||
r.queueStorageFlushLocked(r.storageFlushDelay)
|
||||
r.storageMu.Unlock()
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
@@ -164,17 +232,25 @@ func (r *extensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
if err := r.mutateStorage(func(storage map[string]any) bool {
|
||||
if _, exists := storage[key]; !exists {
|
||||
return false
|
||||
}
|
||||
delete(storage, key)
|
||||
return true
|
||||
}); err != nil {
|
||||
GoLog("[Extension:%s] Storage save error: %v\n", r.extensionID, err)
|
||||
if err := r.ensureStorageLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Storage load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
r.storageMu.Lock()
|
||||
if r.storageClosed {
|
||||
r.storageMu.Unlock()
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
if _, exists := r.storageCache[key]; !exists {
|
||||
r.storageMu.Unlock()
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
delete(r.storageCache, key)
|
||||
r.storageDirty = true
|
||||
r.queueStorageFlushLocked(r.storageFlushDelay)
|
||||
r.storageMu.Unlock()
|
||||
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
@@ -189,12 +265,6 @@ func (r *extensionRuntime) getSaltPath() string {
|
||||
func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
saltPath := r.getSaltPath()
|
||||
|
||||
// Serialize concurrent runtimes: if two generated different salts, the
|
||||
// loser's credentials would become undecryptable.
|
||||
mu := extensionFileMu(saltPath)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
salt, err := os.ReadFile(saltPath)
|
||||
if err == nil && len(salt) == 32 {
|
||||
return salt, nil
|
||||
@@ -205,7 +275,7 @@ func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
return nil, fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
|
||||
if err := writeExtensionFileLocked(saltPath, salt); err != nil {
|
||||
if err := os.WriteFile(saltPath, salt, 0600); err != nil {
|
||||
return nil, fmt.Errorf("failed to save salt: %w", err)
|
||||
}
|
||||
|
||||
@@ -223,93 +293,115 @@ func (r *extensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||
return hash[:], nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) readCredentialsFileLocked() (map[string]any, error) {
|
||||
data, err := os.ReadFile(r.getCredentialsPath())
|
||||
func (r *extensionRuntime) ensureCredentialsLoaded() error {
|
||||
r.credentialsMu.RLock()
|
||||
if r.credentialsLoaded {
|
||||
r.credentialsMu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
r.credentialsMu.RUnlock()
|
||||
|
||||
r.credentialsMu.Lock()
|
||||
defer r.credentialsMu.Unlock()
|
||||
if r.credentialsLoaded {
|
||||
return nil
|
||||
}
|
||||
|
||||
credPath := r.getCredentialsPath()
|
||||
data, err := os.ReadFile(credPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]any), nil
|
||||
r.credentialsCache = make(map[string]interface{})
|
||||
r.credentialsLoaded = true
|
||||
return nil
|
||||
}
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
key, err := r.getEncryptionKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get encryption key: %w", err)
|
||||
return fmt.Errorf("failed to get encryption key: %w", err)
|
||||
}
|
||||
decrypted, err := decryptAES(data, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt credentials: %w", err)
|
||||
return fmt.Errorf("failed to decrypt credentials: %w", err)
|
||||
}
|
||||
creds := make(map[string]any)
|
||||
if err := json.Unmarshal(decrypted, &creds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if creds == nil {
|
||||
creds = make(map[string]any)
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) refreshCredentials() error {
|
||||
path := r.getCredentialsPath()
|
||||
fileMu := extensionFileMu(path)
|
||||
fileMu.Lock()
|
||||
snapshot, err := r.readCredentialsFileLocked()
|
||||
fileMu.Unlock()
|
||||
if err != nil {
|
||||
var creds map[string]interface{}
|
||||
if err := json.Unmarshal(decrypted, &creds); err != nil {
|
||||
return err
|
||||
}
|
||||
r.credentialsMu.Lock()
|
||||
r.credentialsCache = snapshot
|
||||
r.credentialsMu.Unlock()
|
||||
if creds == nil {
|
||||
creds = make(map[string]interface{})
|
||||
}
|
||||
|
||||
r.credentialsCache = creds
|
||||
r.credentialsLoaded = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) mutateCredentials(mutate func(map[string]any)) error {
|
||||
path := r.getCredentialsPath()
|
||||
fileMu := extensionFileMu(path)
|
||||
fileMu.Lock()
|
||||
snapshot, err := r.readCredentialsFileLocked()
|
||||
if err == nil {
|
||||
mutate(snapshot)
|
||||
var data []byte
|
||||
data, err = json.Marshal(snapshot)
|
||||
if err == nil {
|
||||
var key []byte
|
||||
key, err = r.getEncryptionKey()
|
||||
if err == nil {
|
||||
data, err = encryptAES(data, key)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = writeExtensionFileLocked(path, data)
|
||||
}
|
||||
}
|
||||
fileMu.Unlock()
|
||||
func (r *extensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
data, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key, err := r.getEncryptionKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get encryption key: %w", err)
|
||||
}
|
||||
encrypted, err := encryptAES(data, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt credentials: %w", err)
|
||||
}
|
||||
|
||||
credPath := r.getCredentialsPath()
|
||||
if err := os.WriteFile(credPath, encrypted, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r.credentialsMu.Lock()
|
||||
r.credentialsCache = snapshot
|
||||
r.credentialsCache = cloneInterfaceMap(creds)
|
||||
r.credentialsLoaded = true
|
||||
r.credentialsMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.jsError("key and value are required")
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "key and value are required",
|
||||
})
|
||||
}
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
value := call.Arguments[1].Export()
|
||||
|
||||
if err := r.mutateCredentials(func(credentials map[string]any) {
|
||||
credentials[key] = value
|
||||
}); err != nil {
|
||||
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||
return r.jsError("%s", err.Error())
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.jsSuccess(nil)
|
||||
r.credentialsMu.RLock()
|
||||
nextCreds := cloneInterfaceMap(r.credentialsCache)
|
||||
r.credentialsMu.RUnlock()
|
||||
nextCreds[key] = value
|
||||
|
||||
if err := r.saveCredentials(nextCreds); err != nil {
|
||||
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||
@@ -319,7 +411,7 @@ func (r *extensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
if err := r.refreshCredentials(); err != nil {
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
}
|
||||
@@ -344,9 +436,17 @@ func (r *extensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
if err := r.mutateCredentials(func(credentials map[string]any) {
|
||||
delete(credentials, key)
|
||||
}); err != nil {
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
GoLog("[Extension:%s] Credentials load error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
r.credentialsMu.RLock()
|
||||
nextCreds := cloneInterfaceMap(r.credentialsCache)
|
||||
r.credentialsMu.RUnlock()
|
||||
delete(nextCreds, key)
|
||||
|
||||
if err := r.saveCredentials(nextCreds); err != nil {
|
||||
GoLog("[Extension:%s] Credentials save error: %v\n", r.extensionID, err)
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -361,7 +461,7 @@ func (r *extensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||
|
||||
key := call.Arguments[0].String()
|
||||
|
||||
if err := r.refreshCredentials(); err != nil {
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func setStorageValue(t *testing.T, runtime *extensionRuntime, key string, value any) {
|
||||
func setStorageValue(t *testing.T, runtime *extensionRuntime, key string, value interface{}) {
|
||||
t.Helper()
|
||||
result := runtime.storageSet(goja.FunctionCall{
|
||||
Arguments: []goja.Value{
|
||||
@@ -24,91 +24,21 @@ func setStorageValue(t *testing.T, runtime *extensionRuntime, key string, value
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntimeStorageConcurrentRuntimesMergeWrites(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
ext := &loadedExtension{ID: "merge-test", Manifest: &ExtensionManifest{Name: "merge-test"}, DataDir: dataDir}
|
||||
runtimeA := newExtensionRuntime(ext)
|
||||
runtimeB := newExtensionRuntime(ext)
|
||||
runtimeA.RegisterAPIs(goja.New())
|
||||
runtimeB.RegisterAPIs(goja.New())
|
||||
|
||||
start := make(chan struct{})
|
||||
done := make(chan bool, 2)
|
||||
go func() {
|
||||
<-start
|
||||
result := runtimeA.storageSet(goja.FunctionCall{Arguments: []goja.Value{
|
||||
runtimeA.vm.ToValue("from_a"), runtimeA.vm.ToValue("a"),
|
||||
}})
|
||||
done <- result.ToBoolean()
|
||||
}()
|
||||
go func() {
|
||||
<-start
|
||||
result := runtimeB.storageSet(goja.FunctionCall{Arguments: []goja.Value{
|
||||
runtimeB.vm.ToValue("from_b"), runtimeB.vm.ToValue("b"),
|
||||
}})
|
||||
done <- result.ToBoolean()
|
||||
}()
|
||||
close(start)
|
||||
if !<-done || !<-done {
|
||||
t.Fatal("concurrent storage write failed")
|
||||
}
|
||||
|
||||
storage := readStorageMap(t, filepath.Join(dataDir, "storage.json"))
|
||||
if storage["from_a"] != "a" || storage["from_b"] != "b" {
|
||||
t.Fatalf("concurrent storage writes were not merged: %#v", storage)
|
||||
}
|
||||
|
||||
credStart := make(chan struct{})
|
||||
credDone := make(chan struct{}, 2)
|
||||
for _, item := range []struct {
|
||||
runtime *extensionRuntime
|
||||
key string
|
||||
}{
|
||||
{runtimeA, "token_a"},
|
||||
{runtimeB, "token_b"},
|
||||
} {
|
||||
item := item
|
||||
go func() {
|
||||
<-credStart
|
||||
result := item.runtime.credentialsStore(goja.FunctionCall{Arguments: []goja.Value{
|
||||
item.runtime.vm.ToValue(item.key),
|
||||
item.runtime.vm.ToValue(item.key + "_value"),
|
||||
}})
|
||||
if success, _ := result.Export().(map[string]any)["success"].(bool); !success {
|
||||
t.Errorf("credentialsStore(%s) failed", item.key)
|
||||
}
|
||||
credDone <- struct{}{}
|
||||
}()
|
||||
}
|
||||
close(credStart)
|
||||
<-credDone
|
||||
<-credDone
|
||||
|
||||
reader := newExtensionRuntime(ext)
|
||||
reader.RegisterAPIs(goja.New())
|
||||
for _, key := range []string{"token_a", "token_b"} {
|
||||
got := reader.credentialsGet(goja.FunctionCall{Arguments: []goja.Value{reader.vm.ToValue(key)}}).String()
|
||||
if got != key+"_value" {
|
||||
t.Fatalf("credential %s = %q", key, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readStorageMap(t *testing.T, storagePath string) map[string]any {
|
||||
func readStorageMap(t *testing.T, storagePath string) map[string]interface{} {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(storagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read storage file: %v", err)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("failed to unmarshal storage file: %v", err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func TestExtensionRuntimeStorage_AtomicWriteCompactJSON(t *testing.T) {
|
||||
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
ID: "storage-test",
|
||||
Manifest: &ExtensionManifest{
|
||||
@@ -118,6 +48,7 @@ func TestExtensionRuntimeStorage_AtomicWriteCompactJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
runtime := newExtensionRuntime(ext)
|
||||
runtime.storageFlushDelay = 25 * time.Millisecond
|
||||
runtime.RegisterAPIs(goja.New())
|
||||
|
||||
setStorageValue(t, runtime, "k1", "v1")
|
||||
@@ -139,7 +70,7 @@ func TestExtensionRuntimeStorage_AtomicWriteCompactJSON(t *testing.T) {
|
||||
t.Fatalf("storage.json was not written within timeout")
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("failed to unmarshal storage file: %v", err)
|
||||
}
|
||||
@@ -165,6 +96,7 @@ func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
|
||||
}
|
||||
|
||||
runtime := newExtensionRuntime(ext)
|
||||
runtime.storageFlushDelay = time.Hour
|
||||
runtime.RegisterAPIs(ext.VM)
|
||||
ext.runtime = runtime
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
|
||||
Network: []string{"auth.example.com", "token.example.com", "api.example.com"},
|
||||
},
|
||||
},
|
||||
settings: map[string]any{},
|
||||
settings: map[string]interface{}{},
|
||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.Host {
|
||||
case "token.example.com":
|
||||
@@ -63,7 +63,7 @@ func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
|
||||
openResult := runtime.authOpenUrl(goja.FunctionCall{Arguments: []goja.Value{
|
||||
vm.ToValue("https://auth.example.com/login"),
|
||||
vm.ToValue("app://callback"),
|
||||
}}).Export().(map[string]any)
|
||||
}}).Export().(map[string]interface{})
|
||||
if openResult["success"] != true {
|
||||
t.Fatalf("authOpenUrl = %#v", openResult)
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
|
||||
if code := runtime.authGetCode(goja.FunctionCall{}); !goja.IsUndefined(code) {
|
||||
t.Fatalf("expected undefined code, got %v", code)
|
||||
}
|
||||
if ok := runtime.authSetCode(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(map[string]any{"code": "abc", "access_token": "access", "refresh_token": "refresh", "expires_in": float64(60)})}}); !ok.ToBoolean() {
|
||||
if ok := runtime.authSetCode(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(map[string]interface{}{"code": "abc", "access_token": "access", "refresh_token": "refresh", "expires_in": float64(60)})}}); !ok.ToBoolean() {
|
||||
t.Fatal("authSetCode returned false")
|
||||
}
|
||||
if code := runtime.authGetCode(goja.FunctionCall{}).String(); code != "abc" {
|
||||
@@ -82,36 +82,36 @@ func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
|
||||
if !runtime.authIsAuthenticated(goja.FunctionCall{}).ToBoolean() {
|
||||
t.Fatal("expected authenticated runtime")
|
||||
}
|
||||
tokens := runtime.authGetTokens(goja.FunctionCall{}).Export().(map[string]any)
|
||||
tokens := runtime.authGetTokens(goja.FunctionCall{}).Export().(map[string]interface{})
|
||||
if tokens["access_token"] != "access" {
|
||||
t.Fatalf("tokens = %#v", tokens)
|
||||
}
|
||||
|
||||
pkce := runtime.authGeneratePKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(50))}}).Export().(map[string]any)
|
||||
pkce := runtime.authGeneratePKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(50))}}).Export().(map[string]interface{})
|
||||
if pkce["method"] != "S256" || pkce["verifier"] == "" || pkce["challenge"] == "" {
|
||||
t.Fatalf("pkce = %#v", pkce)
|
||||
}
|
||||
if current := runtime.authGetPKCE(goja.FunctionCall{}).Export().(map[string]any); current["verifier"] == "" {
|
||||
if current := runtime.authGetPKCE(goja.FunctionCall{}).Export().(map[string]interface{}); current["verifier"] == "" {
|
||||
t.Fatalf("current pkce = %#v", current)
|
||||
}
|
||||
oauthConfig := map[string]any{
|
||||
oauthConfig := map[string]interface{}{
|
||||
"authUrl": "https://auth.example.com/oauth",
|
||||
"clientId": "client",
|
||||
"redirectUri": "app://callback",
|
||||
"scope": "read",
|
||||
"extraParams": map[string]any{"prompt": "login"},
|
||||
"extraParams": map[string]interface{}{"prompt": "login"},
|
||||
}
|
||||
oauth := runtime.authStartOAuthWithPKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(oauthConfig)}}).Export().(map[string]any)
|
||||
oauth := runtime.authStartOAuthWithPKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(oauthConfig)}}).Export().(map[string]interface{})
|
||||
if oauth["success"] != true || !strings.Contains(oauth["authUrl"].(string), "code_challenge") {
|
||||
t.Fatalf("oauth = %#v", oauth)
|
||||
}
|
||||
tokenConfig := map[string]any{
|
||||
tokenConfig := map[string]interface{}{
|
||||
"tokenUrl": "https://token.example.com/token",
|
||||
"clientId": "client",
|
||||
"redirectUri": "app://callback",
|
||||
"code": "abc",
|
||||
}
|
||||
token := runtime.authExchangeCodeWithPKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(tokenConfig)}}).Export().(map[string]any)
|
||||
token := runtime.authExchangeCodeWithPKCE(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(tokenConfig)}}).Export().(map[string]interface{})
|
||||
if token["success"] != true || token["access_token"] != "access" {
|
||||
t.Fatalf("token = %#v", token)
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("polyfill script: %v", err)
|
||||
}
|
||||
var result map[string]any
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(value.String()), &result); err != nil {
|
||||
t.Fatalf("decode polyfill result: %v", err)
|
||||
}
|
||||
@@ -178,313 +178,16 @@ func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type failingBodyReader struct {
|
||||
data []byte
|
||||
sent bool
|
||||
}
|
||||
|
||||
func (f *failingBodyReader) Read(p []byte) (int, error) {
|
||||
if !f.sent {
|
||||
f.sent = true
|
||||
n := copy(p, f.data)
|
||||
return n, nil
|
||||
}
|
||||
return 0, fmt.Errorf("connection reset")
|
||||
}
|
||||
|
||||
func newFileDownloadTestRuntime(t *testing.T, transport roundTripFunc) *extensionRuntime {
|
||||
t.Helper()
|
||||
return &extensionRuntime{
|
||||
extensionID: "dl-ext",
|
||||
manifest: &ExtensionManifest{
|
||||
Name: "dl-ext",
|
||||
Version: "1.0.0",
|
||||
Permissions: ExtensionPermissions{
|
||||
File: true,
|
||||
Network: []string{"cdn.example.com"},
|
||||
},
|
||||
},
|
||||
dataDir: t.TempDir(),
|
||||
vm: goja.New(),
|
||||
httpClient: &http.Client{Transport: transport},
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDownloadStagesAndPromotes(t *testing.T) {
|
||||
runtime := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("audio-bytes")),
|
||||
ContentLength: int64(len("audio-bytes")),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
|
||||
result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
||||
runtime.vm.ToValue("https://cdn.example.com/track.flac"),
|
||||
runtime.vm.ToValue("out/track.flac"),
|
||||
}}).Export().(map[string]any)
|
||||
if result["success"] != true {
|
||||
t.Fatalf("download result = %#v", result)
|
||||
}
|
||||
|
||||
finalPath := filepath.Join(runtime.dataDir, "out", "track.flac")
|
||||
data, err := os.ReadFile(finalPath)
|
||||
if err != nil || string(data) != "audio-bytes" {
|
||||
t.Fatalf("final file = %q/%v", data, err)
|
||||
}
|
||||
if _, err := os.Stat(stagedDownloadPath(finalPath)); !os.IsNotExist(err) {
|
||||
t.Fatalf("staged file left behind: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDownloadFailureLeavesNoFinalFile(t *testing.T) {
|
||||
runtime := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(&failingBodyReader{data: []byte("partial-aud")}),
|
||||
ContentLength: 1 << 20,
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
|
||||
result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
||||
runtime.vm.ToValue("https://cdn.example.com/track.flac"),
|
||||
runtime.vm.ToValue("out/track.flac"),
|
||||
}}).Export().(map[string]any)
|
||||
if result["success"] != false {
|
||||
t.Fatalf("expected failed download, got %#v", result)
|
||||
}
|
||||
|
||||
finalPath := filepath.Join(runtime.dataDir, "out", "track.flac")
|
||||
if _, err := os.Stat(finalPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("partial download visible at final path: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(stagedDownloadPath(finalPath)); !os.IsNotExist(err) {
|
||||
t.Fatalf("staged file left behind: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDownloadResumesAfterMidBodyCut(t *testing.T) {
|
||||
const full = "hello-world!"
|
||||
var attempts int
|
||||
var resumeRange, resumeIfRange string
|
||||
runtime := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
h := make(http.Header)
|
||||
h.Set("ETag", `"v1"`)
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: h,
|
||||
Body: io.NopCloser(&failingBodyReader{data: []byte(full[:6])}),
|
||||
ContentLength: int64(len(full)),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
resumeRange = req.Header.Get("Range")
|
||||
resumeIfRange = req.Header.Get("If-Range")
|
||||
h := make(http.Header)
|
||||
h.Set("Content-Range", fmt.Sprintf("bytes 6-%d/%d", len(full)-1, len(full)))
|
||||
return &http.Response{
|
||||
StatusCode: 206,
|
||||
Header: h,
|
||||
Body: io.NopCloser(strings.NewReader(full[6:])),
|
||||
ContentLength: int64(len(full) - 6),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
|
||||
result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
||||
runtime.vm.ToValue("https://cdn.example.com/track.flac"),
|
||||
runtime.vm.ToValue("out/track.flac"),
|
||||
}}).Export().(map[string]any)
|
||||
if result["success"] != true {
|
||||
t.Fatalf("download result = %#v", result)
|
||||
}
|
||||
if attempts != 2 || resumeRange != "bytes=6-" || resumeIfRange != `"v1"` {
|
||||
t.Fatalf("attempts=%d range=%q if-range=%q", attempts, resumeRange, resumeIfRange)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(runtime.dataDir, "out", "track.flac"))
|
||||
if err != nil || string(data) != full {
|
||||
t.Fatalf("final file = %q/%v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDownloadResumeRestartsWhenRangeIgnored(t *testing.T) {
|
||||
const full = "hello-world!"
|
||||
var attempts int
|
||||
runtime := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) {
|
||||
attempts++
|
||||
h := make(http.Header)
|
||||
h.Set("ETag", `"v1"`)
|
||||
if attempts == 1 {
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: h,
|
||||
Body: io.NopCloser(&failingBodyReader{data: []byte(full[:6])}),
|
||||
ContentLength: int64(len(full)),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
// Server ignores the Range header and replays the full body.
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: h,
|
||||
Body: io.NopCloser(strings.NewReader(full)),
|
||||
ContentLength: int64(len(full)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
|
||||
result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
||||
runtime.vm.ToValue("https://cdn.example.com/track.flac"),
|
||||
runtime.vm.ToValue("out/track.flac"),
|
||||
}}).Export().(map[string]any)
|
||||
if result["success"] != true {
|
||||
t.Fatalf("download result = %#v", result)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want 2", attempts)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(runtime.dataDir, "out", "track.flac"))
|
||||
if err != nil || string(data) != full {
|
||||
t.Fatalf("final file = %q/%v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetDownloadCancelDropsStaleFlag(t *testing.T) {
|
||||
const itemID = "reset-cancel-item"
|
||||
|
||||
// A cancel issued while nothing is downloading pre-registers a flag that
|
||||
// the next attempt would consume and abort; reset must drop it.
|
||||
cancelDownload(itemID)
|
||||
if !isDownloadCancelled(itemID) {
|
||||
t.Fatal("expected pre-registered cancel flag")
|
||||
}
|
||||
resetDownloadCancel(itemID)
|
||||
if isDownloadCancelled(itemID) {
|
||||
t.Fatal("expected stale cancel flag to be dropped")
|
||||
}
|
||||
|
||||
// A cancel attached to an active download must survive reset.
|
||||
ctx := initDownloadCancel(itemID)
|
||||
cancelDownload(itemID)
|
||||
resetDownloadCancel(itemID)
|
||||
if !isDownloadCancelled(itemID) {
|
||||
t.Fatal("expected active cancel to survive reset")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
default:
|
||||
t.Fatal("expected cancelled context")
|
||||
}
|
||||
clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
func TestParseExtensionTrackValueExplicit(t *testing.T) {
|
||||
vm := goja.New()
|
||||
|
||||
value, err := vm.RunString(`({name: "Song", explicit: true})`)
|
||||
if err != nil {
|
||||
t.Fatalf("RunString: %v", err)
|
||||
}
|
||||
if track := parseExtensionTrackValue(vm, value); !track.Explicit {
|
||||
t.Fatalf("expected explicit track, got %#v", track)
|
||||
}
|
||||
|
||||
value, err = vm.RunString(`({name: "Song", isExplicit: true})`)
|
||||
if err != nil {
|
||||
t.Fatalf("RunString: %v", err)
|
||||
}
|
||||
if track := parseExtensionTrackValue(vm, value); !track.Explicit {
|
||||
t.Fatalf("expected explicit track via isExplicit, got %#v", track)
|
||||
}
|
||||
|
||||
value, err = vm.RunString(`({name: "Song"})`)
|
||||
if err != nil {
|
||||
t.Fatalf("RunString: %v", err)
|
||||
}
|
||||
if track := parseExtensionTrackValue(vm, value); track.Explicit {
|
||||
t.Fatalf("expected clean track, got %#v", track)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeezerTrackIsExplicit(t *testing.T) {
|
||||
if deezerTrackIsExplicit(deezerTrack{}) {
|
||||
t.Fatal("expected clean track by default")
|
||||
}
|
||||
if !deezerTrackIsExplicit(deezerTrack{ExplicitLyrics: true}) {
|
||||
t.Fatal("expected explicit via explicit_lyrics")
|
||||
}
|
||||
if !deezerTrackIsExplicit(deezerTrack{ExplicitContentLyrics: 1}) {
|
||||
t.Fatal("expected explicit via explicit_content_lyrics == 1")
|
||||
}
|
||||
if deezerTrackIsExplicit(deezerTrack{ExplicitContentLyrics: 2}) {
|
||||
t.Fatal("expected unknown (2) to be treated as clean")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionStoreDiskCacheSurvivesRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
registryURL := "https://registry.example.com/registry.json"
|
||||
store := &extensionRepo{
|
||||
registryURL: registryURL,
|
||||
cacheDir: dir,
|
||||
cacheTTL: time.Hour,
|
||||
cache: &repoRegistry{
|
||||
Version: 1,
|
||||
Extensions: []repoExtension{{ID: "ext", Name: "ext", Version: "1.0.0"}},
|
||||
},
|
||||
cacheTime: time.Now(),
|
||||
}
|
||||
store.saveDiskCache()
|
||||
|
||||
// Simulates an app restart: a fresh store loads the disk cache, then the
|
||||
// Dart layer re-applies the same registry URL.
|
||||
restarted := &extensionRepo{cacheDir: dir, cacheTTL: time.Hour}
|
||||
restarted.loadDiskCache()
|
||||
if restarted.getRegistryURL() != registryURL {
|
||||
t.Fatalf("registry URL after restart = %q", restarted.getRegistryURL())
|
||||
}
|
||||
restarted.setRegistryURL(registryURL)
|
||||
if restarted.cache == nil || len(restarted.cache.Extensions) != 1 {
|
||||
t.Fatalf("expected cache to survive re-applying the same registry URL, got %#v", restarted.cache)
|
||||
}
|
||||
|
||||
restarted.setRegistryURL("https://other.example.com/registry.json")
|
||||
if restarted.cache != nil {
|
||||
t.Fatal("expected cache reset after registry URL change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRegistryBody(t *testing.T) {
|
||||
registry, err := parseRegistryBody([]byte(`{"version":1,"extensions":[{"id":"ext","name":"ext","version":"1.0.0"}]}`))
|
||||
if err != nil || len(registry.Extensions) != 1 {
|
||||
t.Fatalf("parseRegistryBody = %#v/%v", registry, err)
|
||||
}
|
||||
|
||||
if _, err := parseRegistryBody([]byte("<!DOCTYPE html><html></html>")); err == nil || !strings.Contains(err.Error(), "web page") {
|
||||
t.Fatalf("expected web page error, got %v", err)
|
||||
}
|
||||
|
||||
if _, err := parseRegistryBody([]byte("not json")); err == nil || !strings.Contains(err.Error(), "failed to parse registry") {
|
||||
t.Fatalf("expected parse error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := &extensionRepo{
|
||||
store := &extensionStore{
|
||||
registryURL: "https://registry.example.com/registry.json",
|
||||
cacheDir: dir,
|
||||
cacheTTL: time.Hour,
|
||||
cache: &repoRegistry{
|
||||
cache: &storeRegistry{
|
||||
Version: 1,
|
||||
UpdatedAt: "2026-05-04",
|
||||
Extensions: []repoExtension{
|
||||
Extensions: []storeExtension{
|
||||
{
|
||||
ID: "coverage-ext",
|
||||
Name: "coverage-ext",
|
||||
@@ -513,7 +216,7 @@ func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
|
||||
cacheTime: time.Now(),
|
||||
}
|
||||
store.saveDiskCache()
|
||||
loadedStore := &extensionRepo{cacheDir: dir}
|
||||
loadedStore := &extensionStore{cacheDir: dir}
|
||||
loadedStore.loadDiskCache()
|
||||
if loadedStore.cache == nil || len(loadedStore.cache.Extensions) != 2 {
|
||||
t.Fatalf("loaded cache = %#v", loadedStore.cache)
|
||||
@@ -569,6 +272,9 @@ func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
|
||||
if cats := store.getCategories(); len(cats) != 5 {
|
||||
t.Fatalf("categories = %#v", cats)
|
||||
}
|
||||
if !containsIgnoreCase("Hello Metadata", "metadata") || findSubstring("abcdef", "cd") != 2 || containsStr("abc", "z") {
|
||||
t.Fatal("string helper mismatch")
|
||||
}
|
||||
if err := requireHTTPSURL("http://example.com", "registry"); err == nil {
|
||||
t.Fatal("expected HTTPS validation error")
|
||||
}
|
||||
@@ -583,7 +289,7 @@ func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
|
||||
t.Fatal("expected cleared store cache")
|
||||
}
|
||||
|
||||
settingsStore := &ExtensionSettingsStore{settings: map[string]map[string]any{}}
|
||||
settingsStore := &ExtensionSettingsStore{settings: map[string]map[string]interface{}{}}
|
||||
if err := settingsStore.SetDataDir(filepath.Join(dir, "settings")); err != nil {
|
||||
t.Fatalf("SetDataDir: %v", err)
|
||||
}
|
||||
@@ -596,7 +302,7 @@ func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
|
||||
if _, err := settingsStore.Get("ext", "missing"); err == nil {
|
||||
t.Fatal("expected missing setting error")
|
||||
}
|
||||
if err := settingsStore.SetAll("ext", map[string]any{"a": float64(1), "_secret": "hidden"}); err != nil {
|
||||
if err := settingsStore.SetAll("ext", map[string]interface{}{"a": float64(1), "_secret": "hidden"}); err != nil {
|
||||
t.Fatalf("settings SetAll: %v", err)
|
||||
}
|
||||
if all := settingsStore.GetAll("ext"); all["a"] != float64(1) {
|
||||
@@ -611,16 +317,17 @@ func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
|
||||
if jsonText, err := settingsStore.GetAllExtensionSettingsJSON(); err != nil || jsonText == "" {
|
||||
t.Fatalf("settings JSON = %q/%v", jsonText, err)
|
||||
}
|
||||
reloaded := &ExtensionSettingsStore{settings: map[string]map[string]any{}}
|
||||
reloaded := &ExtensionSettingsStore{settings: map[string]map[string]interface{}{}}
|
||||
if err := reloaded.SetDataDir(settingsStore.dataDir); err != nil {
|
||||
t.Fatalf("reload settings: %v", err)
|
||||
}
|
||||
|
||||
vm := goja.New()
|
||||
runtime := &extensionRuntime{
|
||||
extensionID: "storage-ext",
|
||||
dataDir: filepath.Join(dir, "runtime"),
|
||||
vm: vm,
|
||||
extensionID: "storage-ext",
|
||||
dataDir: filepath.Join(dir, "runtime"),
|
||||
vm: vm,
|
||||
storageFlushDelay: time.Hour,
|
||||
}
|
||||
if err := os.MkdirAll(runtime.dataDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -628,12 +335,16 @@ func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
|
||||
if got := runtime.storageGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("missing"), vm.ToValue("fallback")}}).String(); got != "fallback" {
|
||||
t.Fatalf("storage fallback = %q", got)
|
||||
}
|
||||
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key"), vm.ToValue(map[string]any{"nested": "value"})}}); !ok.ToBoolean() {
|
||||
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key"), vm.ToValue(map[string]interface{}{"nested": "value"})}}); !ok.ToBoolean() {
|
||||
t.Fatal("storageSet false")
|
||||
}
|
||||
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key"), vm.ToValue(map[string]any{"nested": "value"})}}); !ok.ToBoolean() {
|
||||
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key"), vm.ToValue(map[string]interface{}{"nested": "value"})}}); !ok.ToBoolean() {
|
||||
t.Fatal("storageSet equal false")
|
||||
}
|
||||
loaded, err := runtime.loadStorage()
|
||||
if err != nil || loaded["key"] == nil {
|
||||
t.Fatalf("loadStorage = %#v/%v", loaded, err)
|
||||
}
|
||||
if err := runtime.flushStorageNow(); err != nil {
|
||||
t.Fatalf("flushStorageNow: %v", err)
|
||||
}
|
||||
@@ -653,7 +364,7 @@ func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
|
||||
if err := os.MkdirAll(credRuntime.dataDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result := credRuntime.credentialsStore(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token"), vm.ToValue("secret")}}).Export().(map[string]any); result["success"] != true {
|
||||
if result := credRuntime.credentialsStore(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token"), vm.ToValue("secret")}}).Export().(map[string]interface{}); result["success"] != true {
|
||||
t.Fatalf("credentialsStore = %#v", result)
|
||||
}
|
||||
if got := credRuntime.credentialsGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).String(); got != "secret" {
|
||||
@@ -727,14 +438,14 @@ func TestExtensionRuntimeHTTPMatchingAndMetadataHelpers(t *testing.T) {
|
||||
t.Fatalf("expected domain validation error for %s", rawURL)
|
||||
}
|
||||
}
|
||||
if got := runtime.httpGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/get"), vm.ToValue(map[string]any{"X-Test": "yes"})}}).Export().(map[string]any); got["status"] != 201 || !strings.Contains(got["body"].(string), "GET") {
|
||||
if got := runtime.httpGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/get"), vm.ToValue(map[string]interface{}{"X-Test": "yes"})}}).Export().(map[string]interface{}); got["status"] != 201 || !strings.Contains(got["body"].(string), "GET") {
|
||||
t.Fatalf("httpGet = %#v", got)
|
||||
}
|
||||
if got := runtime.httpPost(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/post"), vm.ToValue(map[string]any{"a": "b"})}}).Export().(map[string]any); !strings.Contains(got["body"].(string), "POST") {
|
||||
if got := runtime.httpPost(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/post"), vm.ToValue(map[string]interface{}{"a": "b"})}}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), "POST") {
|
||||
t.Fatalf("httpPost = %#v", got)
|
||||
}
|
||||
requestOptions := map[string]any{"method": "patch", "body": []any{"x"}, "headers": map[string]any{"X-Req": "1"}}
|
||||
if got := runtime.httpRequest(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/request"), vm.ToValue(requestOptions)}}).Export().(map[string]any); !strings.Contains(got["body"].(string), "PATCH") {
|
||||
requestOptions := map[string]interface{}{"method": "patch", "body": []interface{}{"x"}, "headers": map[string]interface{}{"X-Req": "1"}}
|
||||
if got := runtime.httpRequest(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/request"), vm.ToValue(requestOptions)}}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), "PATCH") {
|
||||
t.Fatalf("httpRequest = %#v", got)
|
||||
}
|
||||
for _, method := range []struct {
|
||||
@@ -743,14 +454,14 @@ func TestExtensionRuntimeHTTPMatchingAndMetadataHelpers(t *testing.T) {
|
||||
args []goja.Value
|
||||
}{
|
||||
{name: "PUT", call: runtime.httpPut, args: []goja.Value{vm.ToValue("https://api.example.com/put"), vm.ToValue("body")}},
|
||||
{name: "DELETE", call: runtime.httpDelete, args: []goja.Value{vm.ToValue("https://api.example.com/delete"), vm.ToValue(map[string]any{"X-Delete": "1"})}},
|
||||
{name: "PATCH", call: runtime.httpPatch, args: []goja.Value{vm.ToValue("https://api.example.com/patch"), vm.ToValue(map[string]any{"p": "q"})}},
|
||||
{name: "DELETE", call: runtime.httpDelete, args: []goja.Value{vm.ToValue("https://api.example.com/delete"), vm.ToValue(map[string]interface{}{"X-Delete": "1"})}},
|
||||
{name: "PATCH", call: runtime.httpPatch, args: []goja.Value{vm.ToValue("https://api.example.com/patch"), vm.ToValue(map[string]interface{}{"p": "q"})}},
|
||||
} {
|
||||
if got := method.call(goja.FunctionCall{Arguments: method.args}).Export().(map[string]any); !strings.Contains(got["body"].(string), method.name) {
|
||||
if got := method.call(goja.FunctionCall{Arguments: method.args}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), method.name) {
|
||||
t.Fatalf("%s = %#v", method.name, got)
|
||||
}
|
||||
}
|
||||
if got := runtime.httpGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/huge")}}).Export().(map[string]any); !strings.Contains(got["error"].(string), "exceeds") {
|
||||
if got := runtime.httpGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://api.example.com/huge")}}).Export().(map[string]interface{}); !strings.Contains(got["error"].(string), "exceeds") {
|
||||
t.Fatalf("huge response = %#v", got)
|
||||
}
|
||||
if !runtime.httpClearCookies(goja.FunctionCall{}).ToBoolean() {
|
||||
@@ -850,14 +561,14 @@ func TestExtensionRuntimeFileAPIs(t *testing.T) {
|
||||
t.Fatalf("absolute validatePath = %q/%v", got, err)
|
||||
}
|
||||
|
||||
write := runtime.fileWrite(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt"), vm.ToValue("hello")}}).Export().(map[string]any)
|
||||
write := runtime.fileWrite(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt"), vm.ToValue("hello")}}).Export().(map[string]interface{})
|
||||
if write["success"] != true {
|
||||
t.Fatalf("fileWrite = %#v", write)
|
||||
}
|
||||
if !runtime.fileExists(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt")}}).ToBoolean() {
|
||||
t.Fatal("expected written file to exist")
|
||||
}
|
||||
read := runtime.fileRead(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt")}}).Export().(map[string]any)
|
||||
read := runtime.fileRead(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/a.txt")}}).Export().(map[string]interface{})
|
||||
if read["data"] != "hello" {
|
||||
t.Fatalf("fileRead = %#v", read)
|
||||
}
|
||||
@@ -865,53 +576,53 @@ func TestExtensionRuntimeFileAPIs(t *testing.T) {
|
||||
writeBytes := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{
|
||||
vm.ToValue("nested/bytes.bin"),
|
||||
vm.ToValue("4869"),
|
||||
vm.ToValue(map[string]any{"encoding": "hex", "truncate": true}),
|
||||
}}).Export().(map[string]any)
|
||||
vm.ToValue(map[string]interface{}{"encoding": "hex", "truncate": true}),
|
||||
}}).Export().(map[string]interface{})
|
||||
if writeBytes["success"] != true {
|
||||
t.Fatalf("fileWriteBytes = %#v", writeBytes)
|
||||
}
|
||||
appendBytes := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{
|
||||
vm.ToValue("nested/bytes.bin"),
|
||||
vm.ToValue([]any{float64('!')}),
|
||||
vm.ToValue(map[string]any{"append": true}),
|
||||
}}).Export().(map[string]any)
|
||||
vm.ToValue([]interface{}{float64('!')}),
|
||||
vm.ToValue(map[string]interface{}{"append": true}),
|
||||
}}).Export().(map[string]interface{})
|
||||
if appendBytes["success"] != true {
|
||||
t.Fatalf("append fileWriteBytes = %#v", appendBytes)
|
||||
}
|
||||
readBytes := runtime.fileReadBytes(goja.FunctionCall{Arguments: []goja.Value{
|
||||
vm.ToValue("nested/bytes.bin"),
|
||||
vm.ToValue(map[string]any{"encoding": "text", "offset": float64(1), "length": float64(2)}),
|
||||
}}).Export().(map[string]any)
|
||||
vm.ToValue(map[string]interface{}{"encoding": "text", "offset": float64(1), "length": float64(2)}),
|
||||
}}).Export().(map[string]interface{})
|
||||
if readBytes["data"] != "i!" || readBytes["bytes_read"] != 2 {
|
||||
t.Fatalf("fileReadBytes = %#v", readBytes)
|
||||
}
|
||||
if bad := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{
|
||||
vm.ToValue("nested/bad.bin"),
|
||||
vm.ToValue("x"),
|
||||
vm.ToValue(map[string]any{"append": true, "offset": float64(1)}),
|
||||
}}).Export().(map[string]any); bad["success"] != false {
|
||||
vm.ToValue(map[string]interface{}{"append": true, "offset": float64(1)}),
|
||||
}}).Export().(map[string]interface{}); bad["success"] != false {
|
||||
t.Fatalf("expected append+offset failure, got %#v", bad)
|
||||
}
|
||||
if bad := runtime.fileReadBytes(goja.FunctionCall{Arguments: []goja.Value{
|
||||
vm.ToValue("nested/bytes.bin"),
|
||||
vm.ToValue(map[string]any{"encoding": "bad"}),
|
||||
}}).Export().(map[string]any); bad["success"] != false {
|
||||
vm.ToValue(map[string]interface{}{"encoding": "bad"}),
|
||||
}}).Export().(map[string]interface{}); bad["success"] != false {
|
||||
t.Fatalf("expected bad encoding failure, got %#v", bad)
|
||||
}
|
||||
|
||||
copyResult := runtime.fileCopy(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/bytes.bin"), vm.ToValue("nested/copy.bin")}}).Export().(map[string]any)
|
||||
copyResult := runtime.fileCopy(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/bytes.bin"), vm.ToValue("nested/copy.bin")}}).Export().(map[string]interface{})
|
||||
if copyResult["success"] != true {
|
||||
t.Fatalf("fileCopy = %#v", copyResult)
|
||||
}
|
||||
moveResult := runtime.fileMove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/copy.bin"), vm.ToValue("nested/moved.bin")}}).Export().(map[string]any)
|
||||
moveResult := runtime.fileMove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/copy.bin"), vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{})
|
||||
if moveResult["success"] != true {
|
||||
t.Fatalf("fileMove = %#v", moveResult)
|
||||
}
|
||||
sizeResult := runtime.fileGetSize(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/moved.bin")}}).Export().(map[string]any)
|
||||
sizeResult := runtime.fileGetSize(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{})
|
||||
if sizeResult["success"] != true || sizeResult["size"] != int64(3) {
|
||||
t.Fatalf("fileGetSize = %#v", sizeResult)
|
||||
}
|
||||
deleteResult := runtime.fileDelete(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/moved.bin")}}).Export().(map[string]any)
|
||||
deleteResult := runtime.fileDelete(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("nested/moved.bin")}}).Export().(map[string]interface{})
|
||||
if deleteResult["success"] != true {
|
||||
t.Fatalf("fileDelete = %#v", deleteResult)
|
||||
}
|
||||
@@ -919,7 +630,7 @@ func TestExtensionRuntimeFileAPIs(t *testing.T) {
|
||||
download := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
||||
vm.ToValue("https://files.example.com/file"),
|
||||
vm.ToValue("downloads/file.bin"),
|
||||
}}).Export().(map[string]any)
|
||||
}}).Export().(map[string]interface{})
|
||||
if download["success"] != true {
|
||||
t.Fatalf("fileDownload = %#v", download)
|
||||
}
|
||||
@@ -930,8 +641,8 @@ func TestExtensionRuntimeFileAPIs(t *testing.T) {
|
||||
chunked := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
||||
vm.ToValue("https://files.example.com/chunk"),
|
||||
vm.ToValue("downloads/chunk.bin"),
|
||||
vm.ToValue(map[string]any{"chunked": float64(2), "headers": map[string]any{"X-Test": "yes"}}),
|
||||
}}).Export().(map[string]any)
|
||||
vm.ToValue(map[string]interface{}{"chunked": float64(2), "headers": map[string]interface{}{"X-Test": "yes"}}),
|
||||
}}).Export().(map[string]interface{})
|
||||
if chunked["success"] != true {
|
||||
t.Fatalf("chunked fileDownload = %#v", chunked)
|
||||
}
|
||||
@@ -939,7 +650,7 @@ func TestExtensionRuntimeFileAPIs(t *testing.T) {
|
||||
t.Fatalf("chunked data = %q/%v", data, err)
|
||||
}
|
||||
|
||||
if missing := runtime.fileDownload(goja.FunctionCall{}).Export().(map[string]any); missing["success"] != false {
|
||||
if missing := runtime.fileDownload(goja.FunctionCall{}).Export().(map[string]interface{}); missing["success"] != false {
|
||||
t.Fatalf("expected missing download args error, got %#v", missing)
|
||||
}
|
||||
}
|
||||
@@ -957,31 +668,31 @@ func TestExtensionRuntimeUtilityAPIs(t *testing.T) {
|
||||
if runtime.hmacSHA256Base64(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("msg"), vm.ToValue("key")}}).String() == "" {
|
||||
t.Fatal("expected hmac sha256 base64")
|
||||
}
|
||||
if value := runtime.hmacSHA1(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue([]any{float64(1), float64(2)}), vm.ToValue([]any{float64(3)})}}); len(value.Export().([]any)) == 0 {
|
||||
if value := runtime.hmacSHA1(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue([]interface{}{float64(1), float64(2)}), vm.ToValue([]interface{}{float64(3)})}}); len(value.Export().([]interface{})) == 0 {
|
||||
t.Fatal("expected hmac sha1 bytes")
|
||||
}
|
||||
if !goja.IsUndefined(runtime.parseJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(`{bad`)}})) {
|
||||
t.Fatal("expected invalid JSON to return undefined")
|
||||
}
|
||||
parsed := runtime.parseJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(`{"ok":true}`)}}).Export().(map[string]any)
|
||||
parsed := runtime.parseJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(`{"ok":true}`)}}).Export().(map[string]interface{})
|
||||
if parsed["ok"] != true {
|
||||
t.Fatalf("parseJSON = %#v", parsed)
|
||||
}
|
||||
if text := runtime.stringifyJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(map[string]any{"ok": true})}}).String(); !strings.Contains(text, "ok") {
|
||||
if text := runtime.stringifyJSON(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(map[string]interface{}{"ok": true})}}).String(); !strings.Contains(text, "ok") {
|
||||
t.Fatalf("stringifyJSON = %q", text)
|
||||
}
|
||||
encrypted := runtime.cryptoEncrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("plain"), vm.ToValue("secret")}}).Export().(map[string]any)
|
||||
encrypted := runtime.cryptoEncrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("plain"), vm.ToValue("secret")}}).Export().(map[string]interface{})
|
||||
if encrypted["success"] != true || encrypted["data"] == "" {
|
||||
t.Fatalf("cryptoEncrypt = %#v", encrypted)
|
||||
}
|
||||
decrypted := runtime.cryptoDecrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(encrypted["data"]), vm.ToValue("secret")}}).Export().(map[string]any)
|
||||
decrypted := runtime.cryptoDecrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(encrypted["data"]), vm.ToValue("secret")}}).Export().(map[string]interface{})
|
||||
if decrypted["success"] != true || decrypted["data"] != "plain" {
|
||||
t.Fatalf("cryptoDecrypt = %#v", decrypted)
|
||||
}
|
||||
if bad := runtime.cryptoDecrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("bad"), vm.ToValue("secret")}}).Export().(map[string]any); bad["success"] != false {
|
||||
if bad := runtime.cryptoDecrypt(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("bad"), vm.ToValue("secret")}}).Export().(map[string]interface{}); bad["success"] != false {
|
||||
t.Fatalf("expected bad decrypt failure, got %#v", bad)
|
||||
}
|
||||
key := runtime.cryptoGenerateKey(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(8))}}).Export().(map[string]any)
|
||||
key := runtime.cryptoGenerateKey(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(8))}}).Export().(map[string]interface{})
|
||||
if key["success"] != true || key["key"] == "" || key["hex"] == "" {
|
||||
t.Fatalf("cryptoGenerateKey = %#v", key)
|
||||
}
|
||||
@@ -1034,28 +745,3 @@ func TestExtensionRuntimeUtilityAPIs(t *testing.T) {
|
||||
t.Fatalf("sanitize wrapper = %q", clean)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifySignedSessionExpiredAsVerification(t *testing.T) {
|
||||
got := classifyDownloadErrorType("Failed to resolve Deezer download: signed session expired")
|
||||
if got != "verification_required" {
|
||||
t.Fatalf("expected verification_required, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationRequiredNoteConsume(t *testing.T) {
|
||||
r := &extensionRuntime{}
|
||||
if got := r.consumeVerificationRequired(); got != "" {
|
||||
t.Fatalf("expected empty before note, got %q", got)
|
||||
}
|
||||
r.noteVerificationRequired("")
|
||||
if got := r.consumeVerificationRequired(); got != "pending" {
|
||||
t.Fatalf("expected pending sentinel, got %q", got)
|
||||
}
|
||||
if got := r.consumeVerificationRequired(); got != "" {
|
||||
t.Fatalf("expected cleared after consume, got %q", got)
|
||||
}
|
||||
r.noteVerificationRequired("https://x/auth")
|
||||
if got := r.consumeVerificationRequired(); got != "https://x/auth" {
|
||||
t.Fatalf("expected auth url, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,24 +16,6 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// jsError returns the standard {"success": false, "error": ...} extension
|
||||
// response.
|
||||
func (r *extensionRuntime) jsError(format string, args ...any) goja.Value {
|
||||
return r.vm.ToValue(map[string]any{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf(format, args...),
|
||||
})
|
||||
}
|
||||
|
||||
// jsSuccess returns kv with "success": true added.
|
||||
func (r *extensionRuntime) jsSuccess(kv map[string]any) goja.Value {
|
||||
if kv == nil {
|
||||
kv = map[string]any{}
|
||||
}
|
||||
kv["success"] = true
|
||||
return r.vm.ToValue(kv)
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
@@ -106,7 +88,7 @@ func (r *extensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
switch k := keyArg.(type) {
|
||||
case string:
|
||||
keyBytes = []byte(k)
|
||||
case []any:
|
||||
case []interface{}:
|
||||
keyBytes = make([]byte, len(k))
|
||||
for i, v := range k {
|
||||
if num, ok := v.(int64); ok {
|
||||
@@ -124,7 +106,7 @@ func (r *extensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
switch m := msgArg.(type) {
|
||||
case string:
|
||||
msgBytes = []byte(m)
|
||||
case []any:
|
||||
case []interface{}:
|
||||
msgBytes = make([]byte, len(m))
|
||||
for i, v := range m {
|
||||
if num, ok := v.(int64); ok {
|
||||
@@ -141,7 +123,7 @@ func (r *extensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
mac.Write(msgBytes)
|
||||
result := mac.Sum(nil)
|
||||
|
||||
jsArray := make([]any, len(result))
|
||||
jsArray := make([]interface{}, len(result))
|
||||
for i, b := range result {
|
||||
jsArray[i] = int(b)
|
||||
}
|
||||
@@ -154,7 +136,7 @@ func (r *extensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
input := call.Arguments[0].String()
|
||||
|
||||
var result any
|
||||
var result interface{}
|
||||
if err := json.Unmarshal([]byte(input), &result); err != nil {
|
||||
GoLog("[Extension:%s] JSON parse error: %v\n", r.extensionID, err)
|
||||
return goja.Undefined()
|
||||
@@ -180,7 +162,10 @@ func (r *extensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||
|
||||
func (r *extensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.jsError("plaintext and key are required")
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "plaintext and key are required",
|
||||
})
|
||||
}
|
||||
|
||||
plaintext := call.Arguments[0].String()
|
||||
@@ -190,17 +175,24 @@ func (r *extensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||
|
||||
encrypted, err := encryptAES([]byte(plaintext), keyHash[:])
|
||||
if err != nil {
|
||||
return r.jsError("%s", err.Error())
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.jsSuccess(map[string]any{
|
||||
"data": base64.StdEncoding.EncodeToString(encrypted),
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": base64.StdEncoding.EncodeToString(encrypted),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.jsError("ciphertext and key are required")
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "ciphertext and key are required",
|
||||
})
|
||||
}
|
||||
|
||||
ciphertextB64 := call.Arguments[0].String()
|
||||
@@ -208,18 +200,25 @@ func (r *extensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextB64)
|
||||
if err != nil {
|
||||
return r.jsError("invalid base64 ciphertext")
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "invalid base64 ciphertext",
|
||||
})
|
||||
}
|
||||
|
||||
keyHash := sha256.Sum256([]byte(keyStr))
|
||||
|
||||
decrypted, err := decryptAES(ciphertext, keyHash[:])
|
||||
if err != nil {
|
||||
return r.jsError("invalid base64 ciphertext")
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "invalid base64 ciphertext",
|
||||
})
|
||||
}
|
||||
|
||||
return r.jsSuccess(map[string]any{
|
||||
"data": string(decrypted),
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": string(decrypted),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -233,12 +232,16 @@ func (r *extensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
|
||||
|
||||
key := make([]byte, length)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return r.jsError("%s", err.Error())
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.jsSuccess(map[string]any{
|
||||
"key": base64.StdEncoding.EncodeToString(key),
|
||||
"hex": hex.EncodeToString(key),
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"key": base64.StdEncoding.EncodeToString(key),
|
||||
"hex": hex.EncodeToString(key),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -394,7 +397,7 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
|
||||
obj.Set("getAudioQuality", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return vm.ToValue(map[string]any{
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": "file path is required",
|
||||
})
|
||||
}
|
||||
@@ -402,12 +405,12 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
filePath := call.Arguments[0].String()
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err != nil {
|
||||
return vm.ToValue(map[string]any{
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return vm.ToValue(map[string]any{
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"bitDepth": quality.BitDepth,
|
||||
"sampleRate": quality.SampleRate,
|
||||
"totalSamples": quality.TotalSamples,
|
||||
@@ -418,7 +421,7 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
|
||||
obj.Set("getLyricsLRC", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 3 {
|
||||
return vm.ToValue(map[string]any{
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": "spotifyID, trackName, and artistName are required",
|
||||
})
|
||||
}
|
||||
@@ -437,19 +440,19 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
|
||||
lyrics, err := GetLyricsLRC(spotifyID, trackName, artistName, filePath, durationMs)
|
||||
if err != nil {
|
||||
return vm.ToValue(map[string]any{
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return vm.ToValue(map[string]any{
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"lyrics": lyrics,
|
||||
})
|
||||
})
|
||||
|
||||
obj.Set("checkISRCExists", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return vm.ToValue(map[string]any{
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": "outputDir and isrc are required",
|
||||
})
|
||||
}
|
||||
@@ -457,13 +460,13 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
outputDir := strings.TrimSpace(call.Arguments[0].String())
|
||||
isrc := strings.TrimSpace(call.Arguments[1].String())
|
||||
if outputDir == "" || isrc == "" {
|
||||
return vm.ToValue(map[string]any{
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": "outputDir and isrc are required",
|
||||
})
|
||||
}
|
||||
|
||||
filePath, exists := checkISRCExistsInternal(outputDir, isrc)
|
||||
return vm.ToValue(map[string]any{
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"exists": exists,
|
||||
"filePath": filePath,
|
||||
})
|
||||
@@ -471,7 +474,7 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
|
||||
obj.Set("addToISRCIndex", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 3 {
|
||||
return vm.ToValue(map[string]any{
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": "outputDir, isrc, and filePath are required",
|
||||
})
|
||||
}
|
||||
@@ -480,13 +483,13 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
isrc := strings.TrimSpace(call.Arguments[1].String())
|
||||
filePath := strings.TrimSpace(call.Arguments[2].String())
|
||||
if outputDir == "" || isrc == "" || filePath == "" {
|
||||
return vm.ToValue(map[string]any{
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"error": "outputDir, isrc, and filePath are required",
|
||||
})
|
||||
}
|
||||
|
||||
AddToISRCIndex(outputDir, isrc, filePath)
|
||||
return vm.ToValue(map[string]any{
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
})
|
||||
})
|
||||
@@ -499,7 +502,7 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
template := call.Arguments[0].String()
|
||||
metadataObj := call.Arguments[1].Export()
|
||||
|
||||
metadata, ok := metadataObj.(map[string]any)
|
||||
metadata, ok := metadataObj.(map[string]interface{})
|
||||
if !ok {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
@@ -512,7 +515,7 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
_, offsetSeconds := now.Zone()
|
||||
offsetMinutes := offsetSeconds / 60
|
||||
|
||||
return vm.ToValue(map[string]any{
|
||||
return vm.ToValue(map[string]interface{}{
|
||||
"year": now.Year(),
|
||||
"month": int(now.Month()),
|
||||
"day": now.Day(),
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
type ExtensionSettingsStore struct {
|
||||
mu sync.RWMutex
|
||||
dataDir string
|
||||
settings map[string]map[string]any // extensionID -> settings
|
||||
settings map[string]map[string]interface{} // extensionID -> settings
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -22,7 +22,7 @@ var (
|
||||
func GetExtensionSettingsStore() *ExtensionSettingsStore {
|
||||
globalSettingsStoreOnce.Do(func() {
|
||||
globalSettingsStore = &ExtensionSettingsStore{
|
||||
settings: make(map[string]map[string]any),
|
||||
settings: make(map[string]map[string]interface{}),
|
||||
}
|
||||
})
|
||||
return globalSettingsStore
|
||||
@@ -68,17 +68,17 @@ func (s *ExtensionSettingsStore) loadAllSettings() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]any, error) {
|
||||
func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]interface{}, error) {
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return make(map[string]any), nil
|
||||
return make(map[string]interface{}), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var settings map[string]any
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func (s *ExtensionSettingsStore) loadSettings(extensionID string) (map[string]an
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]any) error {
|
||||
func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[string]interface{}) error {
|
||||
settingsPath := s.getSettingsPath(extensionID)
|
||||
|
||||
dir := filepath.Dir(settingsPath)
|
||||
@@ -102,7 +102,7 @@ func (s *ExtensionSettingsStore) saveSettings(extensionID string, settings map[s
|
||||
return os.WriteFile(settingsPath, data, 0644)
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) Get(extensionID, key string) (any, error) {
|
||||
func (s *ExtensionSettingsStore) Get(extensionID, key string) (interface{}, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
@@ -118,28 +118,28 @@ func (s *ExtensionSettingsStore) Get(extensionID, key string) (any, error) {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]any {
|
||||
func (s *ExtensionSettingsStore) GetAll(extensionID string) map[string]interface{} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
extSettings, exists := s.settings[extensionID]
|
||||
if !exists {
|
||||
return make(map[string]any)
|
||||
return make(map[string]interface{})
|
||||
}
|
||||
|
||||
result := make(map[string]any)
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range extSettings {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) Set(extensionID, key string, value any) error {
|
||||
func (s *ExtensionSettingsStore) Set(extensionID, key string, value interface{}) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, exists := s.settings[extensionID]; !exists {
|
||||
s.settings[extensionID] = make(map[string]any)
|
||||
s.settings[extensionID] = make(map[string]interface{})
|
||||
}
|
||||
|
||||
s.settings[extensionID][key] = value
|
||||
@@ -147,7 +147,7 @@ func (s *ExtensionSettingsStore) Set(extensionID, key string, value any) error {
|
||||
return s.saveSettings(extensionID, s.settings[extensionID])
|
||||
}
|
||||
|
||||
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]any) error {
|
||||
func (s *ExtensionSettingsStore) SetAll(extensionID string, settings map[string]interface{}) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
|
||||
@@ -1,714 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
const signedSessionRefreshSkew = time.Hour
|
||||
|
||||
var (
|
||||
pendingSignedSessionGrants = make(map[string]string)
|
||||
pendingSignedSessionGrantsMu sync.Mutex
|
||||
)
|
||||
|
||||
type signedSessionRecord struct {
|
||||
InstallID string `json:"install_id"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
SessionSecret string `json:"session_secret,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
AppVersion string `json:"app_version,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
}
|
||||
|
||||
type signedSessionExchangeResponse struct {
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
SessionSecret string `json:"session_secret,omitempty"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
ChallengeID string `json:"challenge_id,omitempty"`
|
||||
ChallengeURL string `json:"challenge_url,omitempty"`
|
||||
AuthURL string `json:"auth_url,omitempty"`
|
||||
}
|
||||
|
||||
func signedSessionConfigWithDefaults(config *SignedSessionConfig) SignedSessionConfig {
|
||||
if config == nil {
|
||||
return SignedSessionConfig{}
|
||||
}
|
||||
resolved := *config
|
||||
if resolved.AppVersion == "" {
|
||||
resolved.AppVersion = "ext-1.0"
|
||||
}
|
||||
if resolved.Platform == "" {
|
||||
resolved.Platform = "extension"
|
||||
}
|
||||
if resolved.CallbackURL == "" {
|
||||
resolved.CallbackURL = "spotiflac://session-grant"
|
||||
}
|
||||
if resolved.SchemeLabel == "" {
|
||||
resolved.SchemeLabel = "SPOTIFLAC-HMAC-V1"
|
||||
}
|
||||
if resolved.HeaderPrefix == "" {
|
||||
resolved.HeaderPrefix = "X-Sig-"
|
||||
}
|
||||
if resolved.TimeWindowSeconds <= 0 {
|
||||
resolved.TimeWindowSeconds = 300
|
||||
}
|
||||
if resolved.Endpoints.Bootstrap == "" {
|
||||
resolved.Endpoints.Bootstrap = "/bootstrap"
|
||||
}
|
||||
if resolved.Endpoints.Challenge == "" {
|
||||
resolved.Endpoints.Challenge = "/challenge"
|
||||
}
|
||||
if resolved.Endpoints.Exchange == "" {
|
||||
resolved.Endpoints.Exchange = "/session/exchange"
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionFilePath(config SignedSessionConfig) (string, error) {
|
||||
namespace := sanitizeSignedSessionNamespace(config.Namespace)
|
||||
if namespace == "" {
|
||||
return "", fmt.Errorf("signed session namespace is empty")
|
||||
}
|
||||
baseDir := filepath.Dir(r.dataDir)
|
||||
if baseDir == "." || baseDir == "" {
|
||||
baseDir = r.dataDir
|
||||
}
|
||||
dir := filepath.Join(baseDir, "signed_sessions")
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
scope := strings.Join([]string{
|
||||
namespace,
|
||||
strings.TrimSpace(strings.ToLower(config.BaseURL)),
|
||||
strings.TrimSpace(strings.ToLower(config.AppVersion)),
|
||||
strings.TrimSpace(strings.ToLower(config.Platform)),
|
||||
}, "\n")
|
||||
sum := sha256.Sum256([]byte(scope))
|
||||
return filepath.Join(dir, namespace+"-"+hex.EncodeToString(sum[:])[:16]+".json"), nil
|
||||
}
|
||||
|
||||
func sanitizeSignedSessionNamespace(namespace string) string {
|
||||
namespace = strings.TrimSpace(strings.ToLower(namespace))
|
||||
var b strings.Builder
|
||||
for _, ch := range namespace {
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' {
|
||||
b.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), ".-_")
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) loadSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
|
||||
path, err := r.signedSessionFilePath(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record := &signedSessionRecord{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
_ = json.Unmarshal(data, record)
|
||||
}
|
||||
if strings.TrimSpace(record.InstallID) == "" {
|
||||
record.InstallID = randomHex(16)
|
||||
}
|
||||
normalizeSignedSessionRecordScope(config, record)
|
||||
if err := r.saveSignedSession(config, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func normalizeSignedSessionRecordScope(config SignedSessionConfig, record *signedSessionRecord) {
|
||||
namespace := sanitizeSignedSessionNamespace(config.Namespace)
|
||||
baseURL := strings.TrimSpace(config.BaseURL)
|
||||
appVersion := strings.TrimSpace(config.AppVersion)
|
||||
platform := strings.TrimSpace(config.Platform)
|
||||
if record.Namespace == "" && record.BaseURL == "" && record.AppVersion == "" && record.Platform == "" {
|
||||
record.Namespace = namespace
|
||||
record.BaseURL = baseURL
|
||||
record.AppVersion = appVersion
|
||||
record.Platform = platform
|
||||
return
|
||||
}
|
||||
if record.Namespace != namespace ||
|
||||
record.BaseURL != baseURL ||
|
||||
record.AppVersion != appVersion ||
|
||||
record.Platform != platform {
|
||||
record.SessionID = ""
|
||||
record.SessionSecret = ""
|
||||
record.ExpiresAt = ""
|
||||
}
|
||||
record.Namespace = namespace
|
||||
record.BaseURL = baseURL
|
||||
record.AppVersion = appVersion
|
||||
record.Platform = platform
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) saveSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
|
||||
path, err := r.signedSessionFilePath(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(record, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0600)
|
||||
}
|
||||
|
||||
func randomHex(bytesLen int) string {
|
||||
buf := make([]byte, bytesLen)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func parseSignedSessionTime(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05.000Z",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func signedSessionRecordIsUsable(record *signedSessionRecord) bool {
|
||||
if record == nil || strings.TrimSpace(record.SessionID) == "" || strings.TrimSpace(record.SessionSecret) == "" {
|
||||
return false
|
||||
}
|
||||
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok {
|
||||
return time.Now().Before(expiresAt)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// preflightSignedSession prepares a signed session before download metadata
|
||||
// enrichment starts. A fresh pending challenge is reused, while bootstrap
|
||||
// responses that can issue a session silently are accepted without prompting
|
||||
// the user. Bootstrap failures remain non-fatal to the caller so the normal
|
||||
// provider path can still surface its more specific error.
|
||||
func (r *extensionRuntime) preflightSignedSession() (bool, error) {
|
||||
if r == nil || r.manifest == nil || r.manifest.SignedSession == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||
if config.Namespace == "" || config.BaseURL == "" {
|
||||
return false, fmt.Errorf("signedSession is not configured")
|
||||
}
|
||||
|
||||
record, err := r.loadSignedSession(config)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if signedSessionRecordIsUsable(record) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if pending := GetPendingAuthRequest(r.extensionID); pending != nil {
|
||||
if time.Since(pending.CreatedAt) < pendingAuthRequestTTL &&
|
||||
strings.TrimSpace(pending.AuthURL) != "" {
|
||||
return true, nil
|
||||
}
|
||||
ClearPendingAuthRequest(r.extensionID)
|
||||
}
|
||||
|
||||
if authURL := r.startSignedSessionVerification(config, "download-preflight"); authURL != "" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Bootstrap may provision a session directly instead of returning a
|
||||
// challenge. Reload the record before treating the empty URL as a failure.
|
||||
record, err = r.loadSignedSession(config)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if signedSessionRecordIsUsable(record) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("signed-session bootstrap did not return a session or verification challenge")
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Value {
|
||||
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||
if config.Namespace == "" || config.BaseURL == "" {
|
||||
return r.vm.ToValue(map[string]any{"authenticated": false, "error": "signedSession is not configured"})
|
||||
}
|
||||
record, err := r.loadSignedSession(config)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]any{"authenticated": false, "error": err.Error()})
|
||||
}
|
||||
authenticated := signedSessionRecordIsUsable(record)
|
||||
return r.vm.ToValue(map[string]any{
|
||||
"authenticated": authenticated,
|
||||
"expires_at": record.ExpiresAt,
|
||||
"install_id": record.InstallID,
|
||||
"session_id": record.SessionID,
|
||||
"app_version": config.AppVersion,
|
||||
"platform": config.Platform,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value {
|
||||
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||
record, err := r.loadSignedSession(config)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()})
|
||||
}
|
||||
record.SessionID = ""
|
||||
record.SessionSecret = ""
|
||||
record.ExpiresAt = ""
|
||||
if err := r.saveSignedSession(config, record); err != nil {
|
||||
return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()})
|
||||
}
|
||||
ClearPendingAuthRequest(r.extensionID)
|
||||
return r.vm.ToValue(map[string]any{"success": true})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionCompleteGrant(call goja.FunctionCall) goja.Value {
|
||||
grant := ""
|
||||
if len(call.Arguments) > 0 {
|
||||
grant = strings.TrimSpace(call.Arguments[0].String())
|
||||
}
|
||||
if grant == "" {
|
||||
pendingSignedSessionGrantsMu.Lock()
|
||||
grant = pendingSignedSessionGrants[r.extensionID]
|
||||
delete(pendingSignedSessionGrants, r.extensionID)
|
||||
pendingSignedSessionGrantsMu.Unlock()
|
||||
}
|
||||
if grant == "" {
|
||||
return r.vm.ToValue(map[string]any{"success": false, "error": "no pending grant"})
|
||||
}
|
||||
if err := r.exchangeSignedSessionGrant(grant); err != nil {
|
||||
return r.vm.ToValue(map[string]any{"success": false, "error": err.Error()})
|
||||
}
|
||||
ClearPendingAuthRequest(r.extensionID)
|
||||
return r.vm.ToValue(map[string]any{"success": true})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error {
|
||||
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||
record, err := r.loadSignedSession(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endpoint, err := signedSessionURL(config, config.Endpoints.Exchange)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{
|
||||
"grant": grant,
|
||||
"install_id": record.InstallID,
|
||||
"app_version": config.AppVersion,
|
||||
"platform": config.Platform,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("session exchange failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var exchanged signedSessionExchangeResponse
|
||||
if err := json.Unmarshal(respBody, &exchanged); err != nil {
|
||||
return fmt.Errorf("invalid session exchange response: %w", err)
|
||||
}
|
||||
if exchanged.SessionID == "" || exchanged.SessionSecret == "" || exchanged.ExpiresAt == "" {
|
||||
return fmt.Errorf("session exchange response missing session fields")
|
||||
}
|
||||
record.SessionID = exchanged.SessionID
|
||||
record.SessionSecret = exchanged.SessionSecret
|
||||
record.ExpiresAt = exchanged.ExpiresAt
|
||||
return r.saveSignedSession(config, record)
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]any{"ok": false, "error": "method and path are required"})
|
||||
}
|
||||
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||
if config.Namespace == "" || config.BaseURL == "" {
|
||||
return r.vm.ToValue(map[string]any{"ok": false, "error": "signedSession is not configured"})
|
||||
}
|
||||
method := strings.ToUpper(strings.TrimSpace(call.Arguments[0].String()))
|
||||
requestPath := call.Arguments[1].String()
|
||||
body := []byte{}
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
switch v := call.Arguments[2].Export().(type) {
|
||||
case string:
|
||||
body = []byte(v)
|
||||
case map[string]any, []any:
|
||||
encoded, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()})
|
||||
}
|
||||
body = encoded
|
||||
default:
|
||||
body = []byte(call.Arguments[2].String())
|
||||
}
|
||||
}
|
||||
extraHeaders := parseGojaHeaders(call.Argument(3).Export())
|
||||
|
||||
record, err := r.ensureSignedSession(config)
|
||||
if err != nil {
|
||||
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
|
||||
return r.signedSessionVerificationRequiredValue(authURL)
|
||||
}
|
||||
return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()})
|
||||
}
|
||||
|
||||
resp, respBody, respHeaders, err := r.doSignedSessionRequest(config, record, method, requestPath, body, extraHeaders)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]any{"ok": false, "error": err.Error()})
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusPreconditionRequired {
|
||||
record.SessionID = ""
|
||||
record.SessionSecret = ""
|
||||
record.ExpiresAt = ""
|
||||
_ = r.saveSignedSession(config, record)
|
||||
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
|
||||
return r.signedSessionVerificationRequiredValue(authURL)
|
||||
}
|
||||
}
|
||||
return r.vm.ToValue(map[string]any{
|
||||
"statusCode": resp.StatusCode,
|
||||
"status": resp.StatusCode,
|
||||
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||
"url": resp.Request.URL.String(),
|
||||
"body": string(respBody),
|
||||
"headers": respHeaders,
|
||||
"retryAfterSeconds": signedSessionRetryAfterSeconds(resp),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value {
|
||||
r.noteVerificationRequired(authURL)
|
||||
return r.vm.ToValue(map[string]any{
|
||||
"ok": false,
|
||||
"needsVerification": true,
|
||||
"error": "VERIFY_REQUIRED",
|
||||
"open_auth_url": authURL,
|
||||
"auth_url": authURL,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) ensureSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
|
||||
record, err := r.loadSignedSession(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record.SessionID == "" || record.SessionSecret == "" {
|
||||
return nil, fmt.Errorf("signed session is not authenticated")
|
||||
}
|
||||
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok {
|
||||
if time.Now().After(expiresAt) {
|
||||
record.SessionID = ""
|
||||
record.SessionSecret = ""
|
||||
record.ExpiresAt = ""
|
||||
_ = r.saveSignedSession(config, record)
|
||||
return nil, fmt.Errorf("signed session expired")
|
||||
}
|
||||
if config.Endpoints.Refresh != "" && time.Until(expiresAt) <= signedSessionRefreshSkew {
|
||||
_ = r.refreshSignedSession(config, record)
|
||||
}
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) refreshSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
|
||||
body, _ := json.Marshal(map[string]string{"install_id": record.InstallID})
|
||||
resp, respBody, _, err := r.doSignedSessionRequest(config, record, http.MethodPost, config.Endpoints.Refresh, body, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("session refresh failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var refreshed signedSessionExchangeResponse
|
||||
if err := json.Unmarshal(respBody, &refreshed); err != nil {
|
||||
return err
|
||||
}
|
||||
changed := false
|
||||
if refreshed.SessionID != "" {
|
||||
record.SessionID = refreshed.SessionID
|
||||
changed = true
|
||||
}
|
||||
if refreshed.SessionSecret != "" {
|
||||
record.SessionSecret = refreshed.SessionSecret
|
||||
changed = true
|
||||
}
|
||||
if refreshed.ExpiresAt != "" && refreshed.ExpiresAt != record.ExpiresAt {
|
||||
record.ExpiresAt = refreshed.ExpiresAt
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
return r.saveSignedSession(config, record)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, _ string) string {
|
||||
record, err := r.loadSignedSession(config)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
bootstrapURL, err := signedSessionURL(config, config.Endpoints.Bootstrap)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parsed, _ := url.Parse(bootstrapURL)
|
||||
query := parsed.Query()
|
||||
query.Set("app_version", config.AppVersion)
|
||||
query.Set("install_id", record.InstallID)
|
||||
parsed.RawQuery = query.Encode()
|
||||
req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes))
|
||||
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return ""
|
||||
}
|
||||
var boot signedSessionExchangeResponse
|
||||
if err := json.Unmarshal(body, &boot); err != nil {
|
||||
return ""
|
||||
}
|
||||
if boot.SessionID != "" && boot.SessionSecret != "" && boot.ExpiresAt != "" {
|
||||
record.SessionID = boot.SessionID
|
||||
record.SessionSecret = boot.SessionSecret
|
||||
record.ExpiresAt = boot.ExpiresAt
|
||||
_ = r.saveSignedSession(config, record)
|
||||
return ""
|
||||
}
|
||||
authURL := boot.AuthURL
|
||||
if authURL == "" && boot.ChallengeURL != "" {
|
||||
authURL = boot.ChallengeURL
|
||||
}
|
||||
if authURL == "" && boot.ChallengeID != "" {
|
||||
authURL = r.buildSignedSessionChallengeURL(config, boot.ChallengeID)
|
||||
}
|
||||
if authURL != "" {
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: r.extensionID,
|
||||
AuthURL: authURL,
|
||||
CallbackURL: config.CallbackURL,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
}
|
||||
return authURL
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) buildSignedSessionChallengeURL(config SignedSessionConfig, challengeID string) string {
|
||||
challengeURL, err := signedSessionURL(config, config.Endpoints.Challenge)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(challengeURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
callback, err := url.Parse(config.CallbackURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
q := callback.Query()
|
||||
q.Set("cb_version", "v2grant")
|
||||
q.Set("state", r.extensionID)
|
||||
callback.RawQuery = q.Encode()
|
||||
|
||||
query := parsed.Query()
|
||||
query.Set("id", challengeID)
|
||||
query.Set("cb", callback.String())
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func signedSessionURL(config SignedSessionConfig, endpoint string) (string, error) {
|
||||
base, err := url.Parse(strings.TrimRight(config.BaseURL, "/") + "/")
|
||||
if err != nil || base.Scheme != "https" || base.Host == "" {
|
||||
return "", fmt.Errorf("invalid signed session baseUrl")
|
||||
}
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
if endpoint == "" {
|
||||
return "", fmt.Errorf("signed session endpoint is empty")
|
||||
}
|
||||
if strings.HasPrefix(endpoint, "https://") {
|
||||
return endpoint, nil
|
||||
}
|
||||
endpoint = strings.TrimLeft(endpoint, "/")
|
||||
ref, _ := url.Parse(endpoint)
|
||||
return base.ResolveReference(ref).String(), nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) doSignedSessionRequest(
|
||||
config SignedSessionConfig,
|
||||
record *signedSessionRecord,
|
||||
method string,
|
||||
requestPath string,
|
||||
body []byte,
|
||||
extraHeaders map[string]string,
|
||||
) (*http.Response, []byte, map[string]any, error) {
|
||||
fullURL, err := signedSessionURL(config, requestPath)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
parsed, err := url.Parse(fullURL)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
nonce := randomHex(12)
|
||||
bodyHashBytes := sha256.Sum256(body)
|
||||
bodyHash := hex.EncodeToString(bodyHashBytes[:])
|
||||
parsedTs, _ := time.Parse("2006-01-02T15:04:05.000Z", ts)
|
||||
window := parsedTs.Unix() / int64(config.TimeWindowSeconds)
|
||||
rollingInput := fmt.Sprintf("%d:%s", window, record.SessionID)
|
||||
rk := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(record.SessionSecret), []byte(rollingInput)))
|
||||
signingInput := strings.Join([]string{
|
||||
config.SchemeLabel,
|
||||
method,
|
||||
parsed.EscapedPath(),
|
||||
"",
|
||||
bodyHash,
|
||||
ts,
|
||||
nonce,
|
||||
record.SessionID,
|
||||
config.AppVersion,
|
||||
config.Platform,
|
||||
}, "\n")
|
||||
sig := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(rk), []byte(signingInput)))
|
||||
|
||||
req, err := http.NewRequest(method, fullURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if len(body) > 0 {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||
prefix := config.HeaderPrefix
|
||||
req.Header.Set(prefix+"Session", record.SessionID)
|
||||
req.Header.Set(prefix+"Timestamp", ts)
|
||||
req.Header.Set(prefix+"Nonce", nonce)
|
||||
req.Header.Set(prefix+"Body-SHA256", bodyHash)
|
||||
req.Header.Set(prefix+"Signature", sig)
|
||||
req.Header.Set(prefix+"App-Version", config.AppVersion)
|
||||
req.Header.Set(prefix+"Platform", config.Platform)
|
||||
for k, v := range extraHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
headers := make(map[string]any)
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 1 {
|
||||
headers[k] = v[0]
|
||||
} else {
|
||||
headers[k] = v
|
||||
}
|
||||
}
|
||||
return resp, respBody, headers, nil
|
||||
}
|
||||
|
||||
func signedSessionRetryAfterSeconds(resp *http.Response) int {
|
||||
if resp == nil {
|
||||
return 0
|
||||
}
|
||||
value := strings.TrimSpace(resp.Header.Get("Retry-After"))
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
if seconds, err := strconv.Atoi(value); err == nil {
|
||||
if seconds < 0 {
|
||||
return 0
|
||||
}
|
||||
return seconds
|
||||
}
|
||||
if retryAt, err := http.ParseTime(value); err == nil {
|
||||
seconds := int(time.Until(retryAt).Seconds())
|
||||
if seconds < 0 {
|
||||
return 0
|
||||
}
|
||||
return seconds
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func hmacSHA256Bytes(key, message []byte) []byte {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write(message)
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func setPendingSignedSessionGrant(extensionID, grant string) {
|
||||
extensionID = strings.TrimSpace(extensionID)
|
||||
grant = strings.TrimSpace(grant)
|
||||
if extensionID == "" || grant == "" {
|
||||
return
|
||||
}
|
||||
pendingSignedSessionGrantsMu.Lock()
|
||||
pendingSignedSessionGrants[extensionID] = grant
|
||||
pendingSignedSessionGrantsMu.Unlock()
|
||||
}
|
||||
@@ -1,930 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
goruntime "runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func TestSanitizeSignedSessionNamespace(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"lowercases", "MyExt", "myext"},
|
||||
{"trims whitespace", " my-ext ", "my-ext"},
|
||||
{"keeps allowed punctuation", "my-ext_v1.2", "my-ext_v1.2"},
|
||||
{"strips spaces and slashes but keeps dots", "my ext/../v1", "myext..v1"},
|
||||
{"strips leading and trailing punctuation", "..--my-ext__..", "my-ext"},
|
||||
{"empty stays empty", "", ""},
|
||||
{"only punctuation collapses to empty", "...", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := sanitizeSignedSessionNamespace(tc.in); got != tc.want {
|
||||
t.Errorf("sanitizeSignedSessionNamespace(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedSessionConfigWithDefaults(t *testing.T) {
|
||||
t.Run("nil config yields zero value", func(t *testing.T) {
|
||||
got := signedSessionConfigWithDefaults(nil)
|
||||
if got != (SignedSessionConfig{}) {
|
||||
t.Errorf("expected zero value config, got %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fills in defaults without a namespace or baseUrl", func(t *testing.T) {
|
||||
got := signedSessionConfigWithDefaults(&SignedSessionConfig{})
|
||||
if got.AppVersion != "ext-1.0" {
|
||||
t.Errorf("AppVersion = %q, want ext-1.0", got.AppVersion)
|
||||
}
|
||||
if got.Platform != "extension" {
|
||||
t.Errorf("Platform = %q, want extension", got.Platform)
|
||||
}
|
||||
if got.CallbackURL != "spotiflac://session-grant" {
|
||||
t.Errorf("CallbackURL = %q", got.CallbackURL)
|
||||
}
|
||||
if got.SchemeLabel != "SPOTIFLAC-HMAC-V1" {
|
||||
t.Errorf("SchemeLabel = %q", got.SchemeLabel)
|
||||
}
|
||||
if got.HeaderPrefix != "X-Sig-" {
|
||||
t.Errorf("HeaderPrefix = %q", got.HeaderPrefix)
|
||||
}
|
||||
if got.TimeWindowSeconds != 300 {
|
||||
t.Errorf("TimeWindowSeconds = %d, want 300", got.TimeWindowSeconds)
|
||||
}
|
||||
if got.Endpoints.Bootstrap != "/bootstrap" || got.Endpoints.Challenge != "/challenge" || got.Endpoints.Exchange != "/session/exchange" {
|
||||
t.Errorf("Endpoints defaults = %+v", got.Endpoints)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves values the manifest already set", func(t *testing.T) {
|
||||
custom := &SignedSessionConfig{
|
||||
Namespace: "tidal",
|
||||
BaseURL: "https://auth.example.com",
|
||||
AppVersion: "5.0",
|
||||
Platform: "mobile",
|
||||
TimeWindowSeconds: 60,
|
||||
Endpoints: SignedSessionEndpoints{Exchange: "/custom/exchange"},
|
||||
}
|
||||
got := signedSessionConfigWithDefaults(custom)
|
||||
if got.Namespace != "tidal" || got.BaseURL != "https://auth.example.com" {
|
||||
t.Errorf("namespace/baseUrl were overwritten: %+v", got)
|
||||
}
|
||||
if got.AppVersion != "5.0" || got.Platform != "mobile" || got.TimeWindowSeconds != 60 {
|
||||
t.Errorf("existing scalars were overwritten: %+v", got)
|
||||
}
|
||||
if got.Endpoints.Exchange != "/custom/exchange" {
|
||||
t.Errorf("Endpoints.Exchange overwritten: %q", got.Endpoints.Exchange)
|
||||
}
|
||||
// Untouched endpoints still get their defaults filled in.
|
||||
if got.Endpoints.Bootstrap != "/bootstrap" {
|
||||
t.Errorf("Endpoints.Bootstrap = %q, want default", got.Endpoints.Bootstrap)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseSignedSessionTime(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
wantOK bool
|
||||
wantUTC string
|
||||
}{
|
||||
{"RFC3339Nano", "2026-05-04T10:00:00.123456789Z", true, "2026-05-04T10:00:00Z"},
|
||||
{"RFC3339", "2026-05-04T10:00:00Z", true, "2026-05-04T10:00:00Z"},
|
||||
{"millisecond layout", "2026-05-04T10:00:00.000Z", true, "2026-05-04T10:00:00Z"},
|
||||
{"empty", "", false, ""},
|
||||
{"garbage", "not-a-time", false, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := parseSignedSessionTime(tc.in)
|
||||
if ok != tc.wantOK {
|
||||
t.Fatalf("parseSignedSessionTime(%q) ok = %v, want %v", tc.in, ok, tc.wantOK)
|
||||
}
|
||||
if ok && got.UTC().Format(time.RFC3339) != tc.wantUTC {
|
||||
t.Errorf("parseSignedSessionTime(%q) = %v, want %v", tc.in, got.UTC().Format(time.RFC3339), tc.wantUTC)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightSignedSession(t *testing.T) {
|
||||
t.Run("reuses a valid session without network", func(t *testing.T) {
|
||||
calls := 0
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL)
|
||||
})
|
||||
runtime := newSignedSessionTestRuntime(t, "preflight-valid", transport)
|
||||
runtime.manifest.SignedSession = &SignedSessionConfig{
|
||||
Namespace: "preflight-valid",
|
||||
BaseURL: "https://auth.example.com",
|
||||
}
|
||||
config := signedSessionConfigWithDefaults(runtime.manifest.SignedSession)
|
||||
record, err := runtime.loadSignedSession(config)
|
||||
if err != nil {
|
||||
t.Fatalf("load session: %v", err)
|
||||
}
|
||||
record.SessionID = "session"
|
||||
record.SessionSecret = "secret"
|
||||
record.ExpiresAt = time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
|
||||
if err := runtime.saveSignedSession(config, record); err != nil {
|
||||
t.Fatalf("save session: %v", err)
|
||||
}
|
||||
|
||||
verificationRequired, err := runtime.preflightSignedSession()
|
||||
if err != nil || verificationRequired {
|
||||
t.Fatalf("preflight = verification:%v error:%v", verificationRequired, err)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("valid session made %d network request(s)", calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reuses a fresh pending challenge", func(t *testing.T) {
|
||||
runtime := newSignedSessionTestRuntime(t, "preflight-pending", roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL)
|
||||
}))
|
||||
runtime.manifest.SignedSession = &SignedSessionConfig{
|
||||
Namespace: "preflight-pending",
|
||||
BaseURL: "https://auth.example.com",
|
||||
}
|
||||
pendingAuthRequestsMu.Lock()
|
||||
pendingAuthRequests[runtime.extensionID] = &PendingAuthRequest{
|
||||
ExtensionID: runtime.extensionID,
|
||||
AuthURL: "https://auth.example.com/challenge",
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
pendingAuthRequestsMu.Unlock()
|
||||
t.Cleanup(func() { ClearPendingAuthRequest(runtime.extensionID) })
|
||||
|
||||
verificationRequired, err := runtime.preflightSignedSession()
|
||||
if err != nil || !verificationRequired {
|
||||
t.Fatalf("preflight = verification:%v error:%v", verificationRequired, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bootstraps a challenge for an unauthenticated session", func(t *testing.T) {
|
||||
calls := 0
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
payload, _ := json.Marshal(signedSessionExchangeResponse{
|
||||
ChallengeURL: "https://auth.example.com/challenge",
|
||||
})
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(string(payload))),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
runtime := newSignedSessionTestRuntime(t, "preflight-challenge", transport)
|
||||
runtime.manifest.SignedSession = &SignedSessionConfig{
|
||||
Namespace: "preflight-challenge",
|
||||
BaseURL: "https://auth.example.com",
|
||||
}
|
||||
t.Cleanup(func() { ClearPendingAuthRequest(runtime.extensionID) })
|
||||
|
||||
verificationRequired, err := runtime.preflightSignedSession()
|
||||
if err != nil || !verificationRequired {
|
||||
t.Fatalf("preflight = verification:%v error:%v", verificationRequired, err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("bootstrap calls = %d, want 1", calls)
|
||||
}
|
||||
if pending := GetPendingAuthRequest(runtime.extensionID); pending == nil || pending.AuthURL == "" {
|
||||
t.Fatalf("pending challenge = %#v", pending)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("accepts a session issued directly by bootstrap", func(t *testing.T) {
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
payload, _ := json.Marshal(signedSessionExchangeResponse{
|
||||
SessionID: "boot-session",
|
||||
SessionSecret: "boot-secret",
|
||||
ExpiresAt: time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
|
||||
})
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(string(payload))),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
runtime := newSignedSessionTestRuntime(t, "preflight-direct", transport)
|
||||
runtime.manifest.SignedSession = &SignedSessionConfig{
|
||||
Namespace: "preflight-direct",
|
||||
BaseURL: "https://auth.example.com",
|
||||
}
|
||||
|
||||
verificationRequired, err := runtime.preflightSignedSession()
|
||||
if err != nil || verificationRequired {
|
||||
t.Fatalf("preflight = verification:%v error:%v", verificationRequired, err)
|
||||
}
|
||||
config := signedSessionConfigWithDefaults(runtime.manifest.SignedSession)
|
||||
record, err := runtime.loadSignedSession(config)
|
||||
if err != nil || record.SessionID != "boot-session" {
|
||||
t.Fatalf("bootstrapped session = %#v error:%v", record, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDownloadWithExtensionsPreflightsBeforeMetadataEnrichment(t *testing.T) {
|
||||
extensionID := "preflight-download"
|
||||
itemID := "preflight-item"
|
||||
RemoveItemProgress(itemID)
|
||||
t.Cleanup(func() { RemoveItemProgress(itemID) })
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
payload, _ := json.Marshal(signedSessionExchangeResponse{
|
||||
ChallengeURL: "https://auth.example.com/challenge",
|
||||
})
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(string(payload))),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
runtime := newSignedSessionTestRuntime(t, extensionID, transport)
|
||||
manifest := &ExtensionManifest{
|
||||
Name: extensionID,
|
||||
Types: []ExtensionType{ExtensionTypeDownloadProvider},
|
||||
SignedSession: &SignedSessionConfig{
|
||||
Namespace: extensionID,
|
||||
BaseURL: "https://auth.example.com",
|
||||
},
|
||||
}
|
||||
runtime.manifest = manifest
|
||||
ext := &loadedExtension{
|
||||
ID: extensionID,
|
||||
Manifest: manifest,
|
||||
VM: runtime.vm,
|
||||
runtime: runtime,
|
||||
initialized: true,
|
||||
Enabled: true,
|
||||
DataDir: runtime.dataDir,
|
||||
}
|
||||
|
||||
manager := getExtensionManager()
|
||||
manager.mu.Lock()
|
||||
previous, hadPrevious := manager.extensions[extensionID]
|
||||
manager.extensions[extensionID] = ext
|
||||
manager.mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
ClearPendingAuthRequest(extensionID)
|
||||
manager.mu.Lock()
|
||||
if hadPrevious {
|
||||
manager.extensions[extensionID] = previous
|
||||
} else {
|
||||
delete(manager.extensions, extensionID)
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
})
|
||||
|
||||
requestJSON := `{"service":"preflight-download","item_id":"preflight-item","isrc":"USRC17607839"}`
|
||||
responseJSON, err := DownloadWithExtensionsJSON(requestJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadWithExtensionsJSON: %v", err)
|
||||
}
|
||||
var response DownloadResponse
|
||||
if err := json.Unmarshal([]byte(responseJSON), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.ErrorType != "verification_required" || response.Service != extensionID {
|
||||
t.Fatalf("response = %#v", response)
|
||||
}
|
||||
if got := GetItemProgress(itemID); got != "{}" {
|
||||
t.Fatalf("verification response left stale progress: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedSessionURL(t *testing.T) {
|
||||
base := SignedSessionConfig{BaseURL: "https://auth.example.com/api"}
|
||||
|
||||
t.Run("joins a relative endpoint onto the base", func(t *testing.T) {
|
||||
got, err := signedSessionURL(base, "/session/exchange")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := "https://auth.example.com/api/session/exchange"
|
||||
if got != want {
|
||||
t.Errorf("signedSessionURL = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("passes an absolute https endpoint through unchanged", func(t *testing.T) {
|
||||
got, err := signedSessionURL(base, "https://other.example.com/challenge")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "https://other.example.com/challenge" {
|
||||
t.Errorf("signedSessionURL = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects an empty endpoint", func(t *testing.T) {
|
||||
if _, err := signedSessionURL(base, ""); err == nil {
|
||||
t.Error("expected error for empty endpoint")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects a non-https base URL", func(t *testing.T) {
|
||||
if _, err := signedSessionURL(SignedSessionConfig{BaseURL: "http://auth.example.com"}, "/x"); err == nil {
|
||||
t.Error("expected error for http:// base URL")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects a base URL with no host", func(t *testing.T) {
|
||||
if _, err := signedSessionURL(SignedSessionConfig{BaseURL: "https:///no-host"}, "/x"); err == nil {
|
||||
t.Error("expected error for base URL without a host")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSignedSessionRetryAfterSeconds(t *testing.T) {
|
||||
t.Run("nil response", func(t *testing.T) {
|
||||
if got := signedSessionRetryAfterSeconds(nil); got != 0 {
|
||||
t.Errorf("got %d, want 0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("numeric seconds", func(t *testing.T) {
|
||||
resp := &http.Response{Header: http.Header{"Retry-After": []string{"30"}}}
|
||||
if got := signedSessionRetryAfterSeconds(resp); got != 30 {
|
||||
t.Errorf("got %d, want 30", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("negative numeric seconds clamp to zero", func(t *testing.T) {
|
||||
resp := &http.Response{Header: http.Header{"Retry-After": []string{"-5"}}}
|
||||
if got := signedSessionRetryAfterSeconds(resp); got != 0 {
|
||||
t.Errorf("got %d, want 0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HTTP-date in the future", func(t *testing.T) {
|
||||
future := time.Now().Add(2 * time.Minute).UTC()
|
||||
resp := &http.Response{Header: http.Header{"Retry-After": []string{future.Format(http.TimeFormat)}}}
|
||||
got := signedSessionRetryAfterSeconds(resp)
|
||||
if got <= 0 || got > 120 {
|
||||
t.Errorf("got %d, want roughly 120", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing header", func(t *testing.T) {
|
||||
resp := &http.Response{Header: http.Header{}}
|
||||
if got := signedSessionRetryAfterSeconds(resp); got != 0 {
|
||||
t.Errorf("got %d, want 0", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeSignedSessionRecordScope(t *testing.T) {
|
||||
config := SignedSessionConfig{Namespace: "Tidal", BaseURL: "https://a.example.com", AppVersion: "1.0", Platform: "mobile"}
|
||||
|
||||
t.Run("first save just stamps the scope", func(t *testing.T) {
|
||||
record := &signedSessionRecord{SessionID: "s1", SessionSecret: "secret"}
|
||||
normalizeSignedSessionRecordScope(config, record)
|
||||
if record.Namespace != "tidal" || record.BaseURL != config.BaseURL {
|
||||
t.Errorf("scope not stamped: %+v", record)
|
||||
}
|
||||
if record.SessionID != "s1" || record.SessionSecret != "secret" {
|
||||
t.Errorf("session fields should survive first stamp: %+v", record)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same scope preserves the session", func(t *testing.T) {
|
||||
record := &signedSessionRecord{
|
||||
Namespace: "tidal", BaseURL: config.BaseURL, AppVersion: config.AppVersion, Platform: config.Platform,
|
||||
SessionID: "s1", SessionSecret: "secret", ExpiresAt: "later",
|
||||
}
|
||||
normalizeSignedSessionRecordScope(config, record)
|
||||
if record.SessionID != "s1" || record.SessionSecret != "secret" || record.ExpiresAt != "later" {
|
||||
t.Errorf("unexpected wipe on matching scope: %+v", record)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("changed scope wipes the session secret", func(t *testing.T) {
|
||||
record := &signedSessionRecord{
|
||||
Namespace: "tidal", BaseURL: "https://old.example.com", AppVersion: config.AppVersion, Platform: config.Platform,
|
||||
SessionID: "s1", SessionSecret: "secret", ExpiresAt: "later",
|
||||
}
|
||||
normalizeSignedSessionRecordScope(config, record)
|
||||
if record.SessionID != "" || record.SessionSecret != "" || record.ExpiresAt != "" {
|
||||
t.Errorf("expected session fields to be wiped after scope change: %+v", record)
|
||||
}
|
||||
if record.BaseURL != config.BaseURL {
|
||||
t.Errorf("BaseURL not updated to new scope: %q", record.BaseURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newSignedSessionTestRuntime(t *testing.T, extensionID string, transport roundTripFunc) *extensionRuntime {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
return &extensionRuntime{
|
||||
extensionID: extensionID,
|
||||
manifest: &ExtensionManifest{Name: extensionID},
|
||||
dataDir: dataDir,
|
||||
vm: goja.New(),
|
||||
httpClient: &http.Client{Transport: transport},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedSessionFilePathDeterminism(t *testing.T) {
|
||||
runtime := newSignedSessionTestRuntime(t, "tidal-ext", nil)
|
||||
|
||||
configA := SignedSessionConfig{Namespace: "tidal", BaseURL: "https://a.example.com"}
|
||||
configB := SignedSessionConfig{Namespace: "tidal", BaseURL: "https://b.example.com"}
|
||||
|
||||
pathA1, err := runtime.signedSessionFilePath(configA)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
pathA2, err := runtime.signedSessionFilePath(configA)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pathA1 != pathA2 {
|
||||
t.Errorf("same config produced different paths: %q vs %q", pathA1, pathA2)
|
||||
}
|
||||
|
||||
pathB, err := runtime.signedSessionFilePath(configB)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if pathA1 == pathB {
|
||||
t.Errorf("different baseUrl scopes collided on the same file: %q", pathA1)
|
||||
}
|
||||
|
||||
if _, err := runtime.signedSessionFilePath(SignedSessionConfig{Namespace: ""}); err == nil {
|
||||
t.Error("expected error for empty namespace")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAndSaveSignedSessionRoundTrip(t *testing.T) {
|
||||
runtime := newSignedSessionTestRuntime(t, "tidal-ext", nil)
|
||||
config := SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"}
|
||||
|
||||
record, err := runtime.loadSignedSession(config)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if record.InstallID == "" {
|
||||
t.Fatal("expected a generated install_id on first load")
|
||||
}
|
||||
|
||||
path, err := runtime.signedSessionFilePath(config)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("expected session file to be persisted: %v", err)
|
||||
}
|
||||
// Windows does not preserve Unix permission bits, so only assert on POSIX.
|
||||
if goruntime.GOOS != "windows" {
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("session file perm = %o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
record.SessionID = "sess-1"
|
||||
record.SessionSecret = "top-secret"
|
||||
record.ExpiresAt = "2030-01-01T00:00:00Z"
|
||||
if err := runtime.saveSignedSession(config, record); err != nil {
|
||||
t.Fatalf("unexpected error saving: %v", err)
|
||||
}
|
||||
|
||||
reloaded, err := runtime.loadSignedSession(config)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error reloading: %v", err)
|
||||
}
|
||||
if reloaded.InstallID != record.InstallID {
|
||||
t.Errorf("install_id changed across reload: %q vs %q", reloaded.InstallID, record.InstallID)
|
||||
}
|
||||
if reloaded.SessionID != "sess-1" || reloaded.SessionSecret != "top-secret" || reloaded.ExpiresAt != "2030-01-01T00:00:00Z" {
|
||||
t.Errorf("session fields did not round-trip: %+v", reloaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedSessionStatusAndClear(t *testing.T) {
|
||||
runtime := newSignedSessionTestRuntime(t, "tidal-ext", nil)
|
||||
runtime.manifest.SignedSession = &SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"}
|
||||
|
||||
readStatus := func() map[string]any {
|
||||
v := runtime.signedSessionStatus(goja.FunctionCall{})
|
||||
return v.Export().(map[string]any)
|
||||
}
|
||||
|
||||
if status := readStatus(); status["authenticated"] != false {
|
||||
t.Fatalf("expected unauthenticated before any grant, got %+v", status)
|
||||
}
|
||||
|
||||
config := signedSessionConfigWithDefaults(runtime.manifest.SignedSession)
|
||||
record, err := runtime.loadSignedSession(config)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
record.SessionID = "sess-1"
|
||||
record.SessionSecret = "secret"
|
||||
record.ExpiresAt = time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
if err := runtime.saveSignedSession(config, record); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if status := readStatus(); status["authenticated"] != true {
|
||||
t.Fatalf("expected authenticated after saving a live session, got %+v", status)
|
||||
}
|
||||
|
||||
record.ExpiresAt = time.Now().Add(-time.Hour).UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
if err := runtime.saveSignedSession(config, record); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if status := readStatus(); status["authenticated"] != false {
|
||||
t.Fatalf("expected expired session to report unauthenticated, got %+v", status)
|
||||
}
|
||||
|
||||
// Restore a live session, then confirm clear() wipes it.
|
||||
record.ExpiresAt = time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
if err := runtime.saveSignedSession(config, record); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
clearResult := runtime.signedSessionClear(goja.FunctionCall{}).Export().(map[string]any)
|
||||
if clearResult["success"] != true {
|
||||
t.Fatalf("expected clear to succeed, got %+v", clearResult)
|
||||
}
|
||||
if status := readStatus(); status["authenticated"] != false {
|
||||
t.Fatalf("expected unauthenticated after clear, got %+v", status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoSignedSessionRequestSignature is the highest-value test in this file:
|
||||
// it recomputes the HMAC-SHA256 rolling-key signature server-side from the
|
||||
// headers the client actually sent, the same way a real backend would, to
|
||||
// guard against silent regressions in the signing scheme (field order,
|
||||
// rolling-key derivation, or header names).
|
||||
func TestDoSignedSessionRequestSignature(t *testing.T) {
|
||||
const sessionSecret = "shhh-its-a-secret"
|
||||
const sessionID = "sess-42"
|
||||
|
||||
var capturedErr string
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
config := signedSessionConfigWithDefaults(&SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"})
|
||||
prefix := config.HeaderPrefix
|
||||
|
||||
ts := req.Header.Get(prefix + "Timestamp")
|
||||
nonce := req.Header.Get(prefix + "Nonce")
|
||||
bodyHash := req.Header.Get(prefix + "Body-SHA256")
|
||||
gotSig := req.Header.Get(prefix + "Signature")
|
||||
gotSession := req.Header.Get(prefix + "Session")
|
||||
|
||||
if gotSession != sessionID {
|
||||
capturedErr = "unexpected session id header: " + gotSession
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(req.Body)
|
||||
wantBodyHashBytes := sha256.Sum256(bodyBytes)
|
||||
wantBodyHash := hex.EncodeToString(wantBodyHashBytes[:])
|
||||
if bodyHash != wantBodyHash {
|
||||
capturedErr = "body hash mismatch"
|
||||
}
|
||||
|
||||
parsedTs, err := time.Parse("2006-01-02T15:04:05.000Z", ts)
|
||||
if err != nil {
|
||||
capturedErr = "bad timestamp: " + err.Error()
|
||||
}
|
||||
window := parsedTs.Unix() / int64(config.TimeWindowSeconds)
|
||||
rollingInput := fmt.Sprintf("%d:%s", window, sessionID)
|
||||
rk := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(sessionSecret), []byte(rollingInput)))
|
||||
signingInput := strings.Join([]string{
|
||||
config.SchemeLabel,
|
||||
req.Method,
|
||||
req.URL.EscapedPath(),
|
||||
"",
|
||||
bodyHash,
|
||||
ts,
|
||||
nonce,
|
||||
sessionID,
|
||||
config.AppVersion,
|
||||
config.Platform,
|
||||
}, "\n")
|
||||
wantSig := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(rk), []byte(signingInput)))
|
||||
|
||||
if !hmac.Equal([]byte(gotSig), []byte(wantSig)) {
|
||||
capturedErr = "signature mismatch: got " + gotSig + " want " + wantSig
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"ok":true}`)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})
|
||||
|
||||
runtime := newSignedSessionTestRuntime(t, "tidal-ext", transport)
|
||||
config := signedSessionConfigWithDefaults(&SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"})
|
||||
record := &signedSessionRecord{InstallID: "install-1", SessionID: sessionID, SessionSecret: sessionSecret}
|
||||
|
||||
resp, body, _, err := runtime.doSignedSessionRequest(config, record, http.MethodPost, "/tracks/search", []byte(`{"q":"test"}`), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if capturedErr != "" {
|
||||
t.Fatalf("signature verification failed: %s", capturedErr)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if string(body) != `{"ok":true}` {
|
||||
t.Errorf("body = %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedSessionFetchUnauthenticatedTriggersVerification(t *testing.T) {
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if strings.Contains(req.URL.Path, "/bootstrap") {
|
||||
payload := signedSessionExchangeResponse{AuthURL: "https://auth.example.com/login?state=abc"}
|
||||
body, _ := json.Marshal(payload)
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(string(body))),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
t.Fatalf("unexpected request to %s", req.URL.String())
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
runtime := newSignedSessionTestRuntime(t, "tidal-ext", transport)
|
||||
runtime.manifest.SignedSession = &SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"}
|
||||
|
||||
call := goja.FunctionCall{Arguments: []goja.Value{runtime.vm.ToValue("GET"), runtime.vm.ToValue("/tracks/search")}}
|
||||
result := runtime.signedSessionFetch(call).Export().(map[string]any)
|
||||
|
||||
if result["ok"] != false {
|
||||
t.Fatalf("expected ok=false when unauthenticated, got %+v", result)
|
||||
}
|
||||
if result["needsVerification"] != true {
|
||||
t.Fatalf("expected needsVerification=true, got %+v", result)
|
||||
}
|
||||
if result["auth_url"] != "https://auth.example.com/login?state=abc" {
|
||||
t.Fatalf("unexpected auth_url: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedSessionFetchRevokesSessionOn401(t *testing.T) {
|
||||
calls := 0
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
calls++
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/bootstrap"):
|
||||
payload := signedSessionExchangeResponse{AuthURL: "https://auth.example.com/login"}
|
||||
body, _ := json.Marshal(payload)
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(body))), Request: req}, nil
|
||||
default:
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||
}
|
||||
})
|
||||
|
||||
runtime := newSignedSessionTestRuntime(t, "tidal-ext", transport)
|
||||
config := SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"}
|
||||
runtime.manifest.SignedSession = &config
|
||||
|
||||
resolved := signedSessionConfigWithDefaults(&config)
|
||||
record, err := runtime.loadSignedSession(resolved)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
record.SessionID = "sess-1"
|
||||
record.SessionSecret = "secret"
|
||||
record.ExpiresAt = time.Now().Add(time.Hour).UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
if err := runtime.saveSignedSession(resolved, record); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
call := goja.FunctionCall{Arguments: []goja.Value{runtime.vm.ToValue("GET"), runtime.vm.ToValue("/tracks/search")}}
|
||||
result := runtime.signedSessionFetch(call).Export().(map[string]any)
|
||||
if result["ok"] != false || result["needsVerification"] != true {
|
||||
t.Fatalf("expected a verification-required response after 401, got %+v", result)
|
||||
}
|
||||
|
||||
reloaded, err := runtime.loadSignedSession(resolved)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if reloaded.SessionID != "" || reloaded.SessionSecret != "" {
|
||||
t.Fatalf("expected session to be wiped after 401, got %+v", reloaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeSignedSessionGrant(t *testing.T) {
|
||||
t.Run("success stores the exchanged session", func(t *testing.T) {
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.HasSuffix(req.URL.Path, "/session/exchange") {
|
||||
t.Fatalf("unexpected path: %s", req.URL.Path)
|
||||
}
|
||||
payload := signedSessionExchangeResponse{SessionID: "sess-9", SessionSecret: "secret-9", ExpiresAt: "2030-01-01T00:00:00Z"}
|
||||
body, _ := json.Marshal(payload)
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(body))), Request: req}, nil
|
||||
})
|
||||
runtime := newSignedSessionTestRuntime(t, "tidal-ext", transport)
|
||||
runtime.manifest.SignedSession = &SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"}
|
||||
|
||||
if err := runtime.exchangeSignedSessionGrant("grant-token"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
config := signedSessionConfigWithDefaults(runtime.manifest.SignedSession)
|
||||
record, err := runtime.loadSignedSession(config)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if record.SessionID != "sess-9" || record.SessionSecret != "secret-9" {
|
||||
t.Fatalf("session was not persisted after exchange: %+v", record)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-2xx response is surfaced as an error", func(t *testing.T) {
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 400, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||
})
|
||||
runtime := newSignedSessionTestRuntime(t, "tidal-ext", transport)
|
||||
runtime.manifest.SignedSession = &SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"}
|
||||
|
||||
if err := runtime.exchangeSignedSessionGrant("bad-grant"); err == nil {
|
||||
t.Fatal("expected an error for a non-2xx exchange response")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetPendingSignedSessionGrant(t *testing.T) {
|
||||
pendingSignedSessionGrantsMu.Lock()
|
||||
pendingSignedSessionGrants = make(map[string]string)
|
||||
pendingSignedSessionGrantsMu.Unlock()
|
||||
|
||||
setPendingSignedSessionGrant(" ext-a ", " grant-1 ")
|
||||
|
||||
pendingSignedSessionGrantsMu.Lock()
|
||||
got := pendingSignedSessionGrants["ext-a"]
|
||||
pendingSignedSessionGrantsMu.Unlock()
|
||||
|
||||
if got != "grant-1" {
|
||||
t.Fatalf("expected trimmed grant to be stored, got %q", got)
|
||||
}
|
||||
|
||||
setPendingSignedSessionGrant("", "grant-2")
|
||||
setPendingSignedSessionGrant("ext-b", "")
|
||||
|
||||
pendingSignedSessionGrantsMu.Lock()
|
||||
_, hasEmptyExt := pendingSignedSessionGrants[""]
|
||||
_, hasEmptyGrant := pendingSignedSessionGrants["ext-b"]
|
||||
pendingSignedSessionGrantsMu.Unlock()
|
||||
|
||||
if hasEmptyExt || hasEmptyGrant {
|
||||
t.Fatal("expected empty extensionID/grant pairs to be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshSignedSession(t *testing.T) {
|
||||
t.Run("updates changed fields and persists them", func(t *testing.T) {
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.HasSuffix(req.URL.Path, "/session/refresh") {
|
||||
t.Fatalf("unexpected path: %s", req.URL.Path)
|
||||
}
|
||||
payload := signedSessionExchangeResponse{SessionSecret: "rotated-secret", ExpiresAt: "2031-01-01T00:00:00Z"}
|
||||
body, _ := json.Marshal(payload)
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(body))), Request: req}, nil
|
||||
})
|
||||
runtime := newSignedSessionTestRuntime(t, "tidal-ext", transport)
|
||||
config := signedSessionConfigWithDefaults(&SignedSessionConfig{
|
||||
Namespace: "tidal", BaseURL: "https://auth.example.com",
|
||||
Endpoints: SignedSessionEndpoints{Refresh: "/session/refresh"},
|
||||
})
|
||||
record := &signedSessionRecord{InstallID: "install-1", SessionID: "sess-1", SessionSecret: "old-secret", ExpiresAt: "2030-01-01T00:00:00Z"}
|
||||
|
||||
if err := runtime.refreshSignedSession(config, record); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if record.SessionSecret != "rotated-secret" || record.ExpiresAt != "2031-01-01T00:00:00Z" {
|
||||
t.Fatalf("refreshed fields not applied in-memory: %+v", record)
|
||||
}
|
||||
if record.SessionID != "sess-1" {
|
||||
t.Fatalf("session id should be untouched when the response omits it: %q", record.SessionID)
|
||||
}
|
||||
|
||||
reloaded, err := runtime.loadSignedSession(config)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if reloaded.SessionSecret != "rotated-secret" {
|
||||
t.Fatalf("refresh was not persisted to disk: %+v", reloaded)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-2xx response is surfaced as an error", func(t *testing.T) {
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 500, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||
})
|
||||
runtime := newSignedSessionTestRuntime(t, "tidal-ext", transport)
|
||||
config := signedSessionConfigWithDefaults(&SignedSessionConfig{
|
||||
Namespace: "tidal", BaseURL: "https://auth.example.com",
|
||||
Endpoints: SignedSessionEndpoints{Refresh: "/session/refresh"},
|
||||
})
|
||||
record := &signedSessionRecord{InstallID: "install-1", SessionID: "sess-1", SessionSecret: "old-secret"}
|
||||
|
||||
if err := runtime.refreshSignedSession(config, record); err == nil {
|
||||
t.Fatal("expected an error for a non-2xx refresh response")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSignedSessionCompleteGrant(t *testing.T) {
|
||||
t.Run("uses the grant argument when provided", func(t *testing.T) {
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
payload := signedSessionExchangeResponse{SessionID: "sess-arg", SessionSecret: "secret-arg", ExpiresAt: "2030-01-01T00:00:00Z"}
|
||||
body, _ := json.Marshal(payload)
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(body))), Request: req}, nil
|
||||
})
|
||||
runtime := newSignedSessionTestRuntime(t, "tidal-ext", transport)
|
||||
runtime.manifest.SignedSession = &SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"}
|
||||
|
||||
call := goja.FunctionCall{Arguments: []goja.Value{runtime.vm.ToValue("grant-from-arg")}}
|
||||
result := runtime.signedSessionCompleteGrant(call).Export().(map[string]any)
|
||||
if result["success"] != true {
|
||||
t.Fatalf("expected success, got %+v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to a pending grant registered out of band", func(t *testing.T) {
|
||||
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
payload := signedSessionExchangeResponse{SessionID: "sess-pending", SessionSecret: "secret-pending", ExpiresAt: "2030-01-01T00:00:00Z"}
|
||||
body, _ := json.Marshal(payload)
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(body))), Request: req}, nil
|
||||
})
|
||||
runtime := newSignedSessionTestRuntime(t, "tidal-ext-pending", transport)
|
||||
runtime.manifest.SignedSession = &SignedSessionConfig{Namespace: "tidal", BaseURL: "https://auth.example.com"}
|
||||
setPendingSignedSessionGrant(runtime.extensionID, "pending-grant")
|
||||
|
||||
result := runtime.signedSessionCompleteGrant(goja.FunctionCall{}).Export().(map[string]any)
|
||||
if result["success"] != true {
|
||||
t.Fatalf("expected success, got %+v", result)
|
||||
}
|
||||
|
||||
pendingSignedSessionGrantsMu.Lock()
|
||||
_, stillPending := pendingSignedSessionGrants[runtime.extensionID]
|
||||
pendingSignedSessionGrantsMu.Unlock()
|
||||
if stillPending {
|
||||
t.Fatal("expected the pending grant to be consumed after use")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no grant available reports failure", func(t *testing.T) {
|
||||
runtime := newSignedSessionTestRuntime(t, "tidal-ext-none", nil)
|
||||
result := runtime.signedSessionCompleteGrant(goja.FunctionCall{}).Export().(map[string]any)
|
||||
if result["success"] != false {
|
||||
t.Fatalf("expected failure without a grant, got %+v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildSignedSessionChallengeURL(t *testing.T) {
|
||||
config := signedSessionConfigWithDefaults(&SignedSessionConfig{
|
||||
Namespace: "tidal",
|
||||
BaseURL: "https://auth.example.com",
|
||||
CallbackURL: "spotiflac://session-grant",
|
||||
})
|
||||
runtime := newSignedSessionTestRuntime(t, "tidal-ext", nil)
|
||||
|
||||
got := runtime.buildSignedSessionChallengeURL(config, "chal-123")
|
||||
|
||||
if !strings.HasPrefix(got, "https://auth.example.com/challenge?") {
|
||||
t.Fatalf("unexpected base URL: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "id=chal-123") {
|
||||
t.Fatalf("expected challenge id in query: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "cb=spotiflac%3A%2F%2Fsession-grant") {
|
||||
t.Fatalf("expected encoded callback URL in query: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ const (
|
||||
CategoryIntegration = "integration"
|
||||
)
|
||||
|
||||
type repoExtension struct {
|
||||
type storeExtension struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
@@ -40,7 +40,7 @@ type repoExtension struct {
|
||||
MinAppVersionAlt string `json:"minAppVersion,omitempty"`
|
||||
}
|
||||
|
||||
func (e *repoExtension) getDisplayName() string {
|
||||
func (e *storeExtension) getDisplayName() string {
|
||||
if e.DisplayName != "" {
|
||||
return e.DisplayName
|
||||
}
|
||||
@@ -50,34 +50,34 @@ func (e *repoExtension) getDisplayName() string {
|
||||
return e.Name
|
||||
}
|
||||
|
||||
func (e *repoExtension) getDownloadURL() string {
|
||||
func (e *storeExtension) getDownloadURL() string {
|
||||
if e.DownloadURL != "" {
|
||||
return e.DownloadURL
|
||||
}
|
||||
return e.DownloadURLAlt
|
||||
}
|
||||
|
||||
func (e *repoExtension) getIconURL() string {
|
||||
func (e *storeExtension) getIconURL() string {
|
||||
if e.IconURL != "" {
|
||||
return e.IconURL
|
||||
}
|
||||
return e.IconURLAlt
|
||||
}
|
||||
|
||||
func (e *repoExtension) getMinAppVersion() string {
|
||||
func (e *storeExtension) getMinAppVersion() string {
|
||||
if e.MinAppVersion != "" {
|
||||
return e.MinAppVersion
|
||||
}
|
||||
return e.MinAppVersionAlt
|
||||
}
|
||||
|
||||
type repoRegistry struct {
|
||||
Version int `json:"version"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Extensions []repoExtension `json:"extensions"`
|
||||
type storeRegistry struct {
|
||||
Version int `json:"version"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Extensions []storeExtension `json:"extensions"`
|
||||
}
|
||||
|
||||
type repoExtensionResponse struct {
|
||||
type storeExtensionResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
@@ -95,8 +95,8 @@ type repoExtensionResponse struct {
|
||||
HasUpdate bool `json:"has_update"`
|
||||
}
|
||||
|
||||
func (e *repoExtension) toResponse() repoExtensionResponse {
|
||||
resp := repoExtensionResponse{
|
||||
func (e *storeExtension) toResponse() storeExtensionResponse {
|
||||
resp := storeExtensionResponse{
|
||||
ID: e.ID,
|
||||
Name: e.Name,
|
||||
DisplayName: e.getDisplayName(),
|
||||
@@ -117,18 +117,18 @@ func (e *repoExtension) toResponse() repoExtensionResponse {
|
||||
return resp
|
||||
}
|
||||
|
||||
type extensionRepo struct {
|
||||
type extensionStore struct {
|
||||
registryURL string
|
||||
cacheDir string
|
||||
cache *repoRegistry
|
||||
cache *storeRegistry
|
||||
cacheMu sync.RWMutex
|
||||
cacheTime time.Time
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
globalExtensionRepo *extensionRepo
|
||||
extensionRepoMu sync.Mutex
|
||||
globalExtensionStore *extensionStore
|
||||
extensionStoreMu sync.Mutex
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -136,22 +136,22 @@ const (
|
||||
cacheFileName = "store_cache.json"
|
||||
)
|
||||
|
||||
func initExtensionRepo(cacheDir string) *extensionRepo {
|
||||
extensionRepoMu.Lock()
|
||||
defer extensionRepoMu.Unlock()
|
||||
func initExtensionStore(cacheDir string) *extensionStore {
|
||||
extensionStoreMu.Lock()
|
||||
defer extensionStoreMu.Unlock()
|
||||
|
||||
if globalExtensionRepo == nil {
|
||||
globalExtensionRepo = &extensionRepo{
|
||||
if globalExtensionStore == nil {
|
||||
globalExtensionStore = &extensionStore{
|
||||
registryURL: "",
|
||||
cacheDir: cacheDir,
|
||||
cacheTTL: cacheTTL,
|
||||
}
|
||||
globalExtensionRepo.loadDiskCache()
|
||||
globalExtensionStore.loadDiskCache()
|
||||
}
|
||||
return globalExtensionRepo
|
||||
return globalExtensionStore
|
||||
}
|
||||
|
||||
func (s *extensionRepo) setRegistryURL(registryURL string) {
|
||||
func (s *extensionStore) setRegistryURL(registryURL string) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
@@ -168,22 +168,22 @@ func (s *extensionRepo) setRegistryURL(registryURL string) {
|
||||
os.Remove(cachePath)
|
||||
}
|
||||
|
||||
LogInfo("ExtensionRepo", "Registry URL updated to: %s", registryURL)
|
||||
LogInfo("ExtensionStore", "Registry URL updated to: %s", registryURL)
|
||||
}
|
||||
|
||||
func (s *extensionRepo) getRegistryURL() string {
|
||||
func (s *extensionStore) getRegistryURL() string {
|
||||
s.cacheMu.RLock()
|
||||
defer s.cacheMu.RUnlock()
|
||||
return s.registryURL
|
||||
}
|
||||
|
||||
func getExtensionRepo() *extensionRepo {
|
||||
extensionRepoMu.Lock()
|
||||
defer extensionRepoMu.Unlock()
|
||||
return globalExtensionRepo
|
||||
func getExtensionStore() *extensionStore {
|
||||
extensionStoreMu.Lock()
|
||||
defer extensionStoreMu.Unlock()
|
||||
return globalExtensionStore
|
||||
}
|
||||
|
||||
func (s *extensionRepo) loadDiskCache() {
|
||||
func (s *extensionStore) loadDiskCache() {
|
||||
if s.cacheDir == "" {
|
||||
return
|
||||
}
|
||||
@@ -195,9 +195,8 @@ func (s *extensionRepo) loadDiskCache() {
|
||||
}
|
||||
|
||||
var cacheData struct {
|
||||
RegistryURL string `json:"registry_url"`
|
||||
Registry repoRegistry `json:"registry"`
|
||||
CacheTime int64 `json:"cache_time"`
|
||||
Registry storeRegistry `json:"registry"`
|
||||
CacheTime int64 `json:"cache_time"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &cacheData); err != nil {
|
||||
@@ -206,27 +205,20 @@ func (s *extensionRepo) loadDiskCache() {
|
||||
|
||||
s.cache = &cacheData.Registry
|
||||
s.cacheTime = time.Unix(cacheData.CacheTime, 0)
|
||||
if s.registryURL == "" {
|
||||
// Restore the URL that produced this cache so a later setRegistryURL
|
||||
// with the same URL keeps the cache instead of wiping it.
|
||||
s.registryURL = cacheData.RegistryURL
|
||||
}
|
||||
LogDebug("ExtensionRepo", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
||||
LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions))
|
||||
}
|
||||
|
||||
func (s *extensionRepo) saveDiskCache() {
|
||||
func (s *extensionStore) saveDiskCache() {
|
||||
if s.cacheDir == "" || s.cache == nil {
|
||||
return
|
||||
}
|
||||
|
||||
cacheData := struct {
|
||||
RegistryURL string `json:"registry_url"`
|
||||
Registry repoRegistry `json:"registry"`
|
||||
CacheTime int64 `json:"cache_time"`
|
||||
Registry storeRegistry `json:"registry"`
|
||||
CacheTime int64 `json:"cache_time"`
|
||||
}{
|
||||
RegistryURL: s.registryURL,
|
||||
Registry: *s.cache,
|
||||
CacheTime: s.cacheTime.Unix(),
|
||||
Registry: *s.cache,
|
||||
CacheTime: s.cacheTime.Unix(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(cacheData)
|
||||
@@ -238,7 +230,7 @@ func (s *extensionRepo) saveDiskCache() {
|
||||
os.WriteFile(cachePath, data, 0644)
|
||||
}
|
||||
|
||||
func (s *extensionRepo) fetchRegistry(forceRefresh bool) (*repoRegistry, error) {
|
||||
func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
@@ -247,7 +239,7 @@ func (s *extensionRepo) fetchRegistry(forceRefresh bool) (*repoRegistry, error)
|
||||
}
|
||||
|
||||
if !forceRefresh && s.cache != nil && time.Since(s.cacheTime) < s.cacheTTL {
|
||||
LogDebug("ExtensionRepo", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
||||
LogDebug("ExtensionStore", "Using cached registry (%d extensions)", len(s.cache.Extensions))
|
||||
return s.cache, nil
|
||||
}
|
||||
|
||||
@@ -255,13 +247,13 @@ func (s *extensionRepo) fetchRegistry(forceRefresh bool) (*repoRegistry, error)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
LogInfo("ExtensionRepo", "Fetching registry from %s", s.registryURL)
|
||||
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
||||
|
||||
client := NewHTTPClientWithTimeout(30 * time.Second)
|
||||
req, err := http.NewRequest(http.MethodGet, s.registryURL, nil)
|
||||
if err != nil {
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionRepo", "Failed to build registry request, using cached registry: %v", err)
|
||||
LogWarn("ExtensionStore", "Failed to build registry request, using cached registry: %v", err)
|
||||
return s.cache, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to build registry request: %w", err)
|
||||
@@ -271,7 +263,7 @@ func (s *extensionRepo) fetchRegistry(forceRefresh bool) (*repoRegistry, error)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionRepo", "Network error, using cached registry: %v", err)
|
||||
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
||||
return s.cache, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to fetch registry: %w", err)
|
||||
@@ -280,7 +272,7 @@ func (s *extensionRepo) fetchRegistry(forceRefresh bool) (*repoRegistry, error)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionRepo", "HTTP %d, using cached registry", resp.StatusCode)
|
||||
LogWarn("ExtensionStore", "HTTP %d, using cached registry", resp.StatusCode)
|
||||
return s.cache, nil
|
||||
}
|
||||
return nil, fmt.Errorf("registry returned HTTP %d", resp.StatusCode)
|
||||
@@ -291,35 +283,20 @@ func (s *extensionRepo) fetchRegistry(forceRefresh bool) (*repoRegistry, error)
|
||||
return nil, fmt.Errorf("failed to read registry: %w", err)
|
||||
}
|
||||
|
||||
registry, err := parseRegistryBody(body)
|
||||
if err != nil {
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionRepo", "Failed to parse registry, using cached registry: %v", err)
|
||||
return s.cache, nil
|
||||
}
|
||||
return nil, err
|
||||
var registry storeRegistry
|
||||
if err := json.Unmarshal(body, ®istry); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
||||
}
|
||||
|
||||
s.cache = registry
|
||||
s.cache = ®istry
|
||||
s.cacheTime = time.Now()
|
||||
s.saveDiskCache()
|
||||
|
||||
LogInfo("ExtensionRepo", "Fetched %d extensions from registry", len(registry.Extensions))
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
func parseRegistryBody(body []byte) (*repoRegistry, error) {
|
||||
var registry repoRegistry
|
||||
if err := json.Unmarshal(body, ®istry); err != nil {
|
||||
if strings.HasPrefix(strings.TrimSpace(string(body)), "<") {
|
||||
return nil, fmt.Errorf("registry URL returned a web page instead of JSON. Make sure the URL points to a registry.json file or a GitHub repository that contains one")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to parse registry: %w", err)
|
||||
}
|
||||
LogInfo("ExtensionStore", "Fetched %d extensions from registry", len(registry.Extensions))
|
||||
return ®istry, nil
|
||||
}
|
||||
|
||||
func (s *extensionRepo) getExtensionsWithStatus(forceRefresh bool) ([]repoExtensionResponse, error) {
|
||||
func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExtensionResponse, error) {
|
||||
registry, err := s.fetchRegistry(forceRefresh)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -334,9 +311,9 @@ func (s *extensionRepo) getExtensionsWithStatus(forceRefresh bool) ([]repoExtens
|
||||
}
|
||||
}
|
||||
|
||||
LogDebug("ExtensionRepo", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
|
||||
LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
|
||||
|
||||
result := make([]repoExtensionResponse, 0, len(registry.Extensions))
|
||||
result := make([]storeExtensionResponse, 0, len(registry.Extensions))
|
||||
for i := range registry.Extensions {
|
||||
ext := ®istry.Extensions[i]
|
||||
resp := ext.toResponse()
|
||||
@@ -349,37 +326,33 @@ func (s *extensionRepo) getExtensionsWithStatus(forceRefresh bool) ([]repoExtens
|
||||
result = append(result, resp)
|
||||
}
|
||||
|
||||
LogDebug("ExtensionRepo", "Built store response payload for %d extensions", len(result))
|
||||
LogDebug("ExtensionStore", "Built store response payload for %d extensions", len(result))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *extensionRepo) findExtension(extensionID string) (*repoExtension, error) {
|
||||
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
|
||||
registry, err := s.fetchRegistry(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
var ext *storeExtension
|
||||
for _, e := range registry.Extensions {
|
||||
if e.ID == extensionID {
|
||||
ext := e
|
||||
return &ext, nil
|
||||
ext = &e
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("extension %s not found in repo", extensionID)
|
||||
}
|
||||
|
||||
func (s *extensionRepo) downloadExtension(extensionID string, destPath string) error {
|
||||
ext, err := s.findExtension(extensionID)
|
||||
if err != nil {
|
||||
return err
|
||||
if ext == nil {
|
||||
return fmt.Errorf("extension %s not found in store", extensionID)
|
||||
}
|
||||
|
||||
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
LogInfo("ExtensionRepo", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
||||
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
||||
|
||||
client := NewHTTPClientWithTimeout(5 * time.Minute)
|
||||
req, err := http.NewRequest(http.MethodGet, ext.getDownloadURL(), nil)
|
||||
@@ -410,7 +383,7 @@ func (s *extensionRepo) downloadExtension(extensionID string, destPath string) e
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
}
|
||||
|
||||
LogInfo("ExtensionRepo", "Downloaded %s to %s", ext.getDisplayName(), destPath)
|
||||
LogInfo("ExtensionStore", "Downloaded %s to %s", ext.getDisplayName(), destPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -445,7 +418,7 @@ func resolveRegistryURL(input string) (string, error) {
|
||||
branch := resolveGitHubDefaultBranch(owner, repo)
|
||||
|
||||
resolved := fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/registry.json", owner, repo, branch)
|
||||
LogInfo("ExtensionRepo", "Resolved %s → %s (branch: %s)", input, resolved, branch)
|
||||
LogInfo("ExtensionStore", "Resolved %s → %s (branch: %s)", input, resolved, branch)
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
@@ -455,13 +428,13 @@ func resolveGitHubDefaultBranch(owner, repo string) string {
|
||||
|
||||
resp, err := client.Get(apiURL)
|
||||
if err != nil {
|
||||
LogWarn("ExtensionRepo", "GitHub API request failed for %s/%s: %v – falling back to main", owner, repo, err)
|
||||
LogWarn("ExtensionStore", "GitHub API request failed for %s/%s: %v – falling back to main", owner, repo, err)
|
||||
return "main"
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
LogWarn("ExtensionRepo", "GitHub API returned %d for %s/%s – falling back to main", resp.StatusCode, owner, repo)
|
||||
LogWarn("ExtensionStore", "GitHub API returned %d for %s/%s – falling back to main", resp.StatusCode, owner, repo)
|
||||
return "main"
|
||||
}
|
||||
|
||||
@@ -469,7 +442,7 @@ func resolveGitHubDefaultBranch(owner, repo string) string {
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil || info.DefaultBranch == "" {
|
||||
LogWarn("ExtensionRepo", "Could not parse default_branch for %s/%s – falling back to main", owner, repo)
|
||||
LogWarn("ExtensionStore", "Could not parse default_branch for %s/%s – falling back to main", owner, repo)
|
||||
return "main"
|
||||
}
|
||||
|
||||
@@ -490,7 +463,7 @@ func requireHTTPSURL(rawURL string, context string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *extensionRepo) getCategories() []string {
|
||||
func (s *extensionStore) getCategories() []string {
|
||||
return []string{
|
||||
CategoryMetadata,
|
||||
CategoryDownload,
|
||||
@@ -500,7 +473,7 @@ func (s *extensionRepo) getCategories() []string {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *extensionRepo) searchExtensions(query string, category string) ([]repoExtensionResponse, error) {
|
||||
func (s *extensionStore) searchExtensions(query string, category string) ([]storeExtensionResponse, error) {
|
||||
extensions, err := s.getExtensionsWithStatus(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -510,8 +483,8 @@ func (s *extensionRepo) searchExtensions(query string, category string) ([]repoE
|
||||
return extensions, nil
|
||||
}
|
||||
|
||||
result := make([]repoExtensionResponse, 0, len(extensions))
|
||||
queryLower := strings.ToLower(query)
|
||||
result := make([]storeExtensionResponse, 0, len(extensions))
|
||||
queryLower := toLower(query)
|
||||
|
||||
for _, ext := range extensions {
|
||||
if category != "" && ext.Category != category {
|
||||
@@ -519,12 +492,12 @@ func (s *extensionRepo) searchExtensions(query string, category string) ([]repoE
|
||||
}
|
||||
|
||||
if query != "" {
|
||||
if !strings.Contains(strings.ToLower(ext.Name), queryLower) &&
|
||||
!strings.Contains(strings.ToLower(ext.DisplayName), queryLower) &&
|
||||
!strings.Contains(strings.ToLower(ext.Description), queryLower) {
|
||||
if !containsIgnoreCase(ext.Name, queryLower) &&
|
||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||
!containsIgnoreCase(ext.Description, queryLower) {
|
||||
found := false
|
||||
for _, tag := range ext.Tags {
|
||||
if strings.Contains(strings.ToLower(tag), queryLower) {
|
||||
if containsIgnoreCase(tag, queryLower) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
@@ -541,7 +514,7 @@ func (s *extensionRepo) searchExtensions(query string, category string) ([]repoE
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *extensionRepo) clearCache() {
|
||||
func (s *extensionStore) clearCache() {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
@@ -553,5 +526,34 @@ func (s *extensionRepo) clearCache() {
|
||||
os.Remove(cachePath)
|
||||
}
|
||||
|
||||
LogInfo("ExtensionRepo", "Cache cleared")
|
||||
LogInfo("ExtensionStore", "Cache cleared")
|
||||
}
|
||||
|
||||
func containsIgnoreCase(s, substr string) bool {
|
||||
return containsStr(toLower(s), substr)
|
||||
}
|
||||
|
||||
func toLower(s string) string {
|
||||
result := make([]byte, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
c += 'a' - 'A'
|
||||
}
|
||||
result[i] = c
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
func containsStr(s, substr string) bool {
|
||||
return len(substr) == 0 || (len(s) >= len(substr) && findSubstring(s, substr) >= 0)
|
||||
}
|
||||
|
||||
func findSubstring(s, substr string) int {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -77,43 +76,6 @@ func TestParseManifest_MissingName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifestRejectsUnsafeExtensionIDs(t *testing.T) {
|
||||
for _, name := range []string{"../escape", `..\\escape`, "/absolute", "UpperCase", "."} {
|
||||
manifest := `{"name":` + strconv.Quote(name) + `,"version":"1.0.0","description":"test","type":["metadata_provider"]}`
|
||||
if _, err := ParseManifest([]byte(manifest)); err == nil {
|
||||
t.Fatalf("expected unsafe extension ID %q to be rejected", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestPrivilegedCapabilitiesRequirePermissions(t *testing.T) {
|
||||
rawFFmpeg := &ExtensionManifest{
|
||||
Name: "raw-ffmpeg",
|
||||
Version: "1.0.0",
|
||||
Description: "test",
|
||||
Types: []ExtensionType{ExtensionTypeDownloadProvider},
|
||||
Capabilities: map[string]any{"rawFfmpeg": true},
|
||||
}
|
||||
if err := rawFFmpeg.Validate(); err == nil {
|
||||
t.Fatal("expected rawFfmpeg without file permission to be rejected")
|
||||
}
|
||||
|
||||
signedSession := &ExtensionManifest{
|
||||
Name: "signed-session",
|
||||
Version: "1.0.0",
|
||||
Description: "test",
|
||||
Types: []ExtensionType{ExtensionTypeDownloadProvider},
|
||||
Permissions: ExtensionPermissions{Network: []string{"api.example.com"}},
|
||||
SignedSession: &SignedSessionConfig{
|
||||
Namespace: "test",
|
||||
BaseURL: "https://api.example.com",
|
||||
},
|
||||
}
|
||||
if err := signedSession.Validate(); err == nil {
|
||||
t.Fatal("expected signedSession without storage permission to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManifest_MissingType(t *testing.T) {
|
||||
invalidManifest := `{
|
||||
"name": "test-provider",
|
||||
@@ -367,7 +329,6 @@ func TestExtensionRuntime_BindDownloadCancelContext(t *testing.T) {
|
||||
|
||||
runtime := newExtensionRuntime(ext)
|
||||
runtime.setActiveDownloadItemID("test-item")
|
||||
initDownloadCancel("test-item")
|
||||
t.Cleanup(func() {
|
||||
clearDownloadCancel("test-item")
|
||||
runtime.clearActiveDownloadItemID()
|
||||
@@ -379,12 +340,6 @@ func TestExtensionRuntime_BindDownloadCancelContext(t *testing.T) {
|
||||
}
|
||||
|
||||
req = runtime.bindDownloadCancelContext(req)
|
||||
cancelMu.Lock()
|
||||
refs := cancelMap["test-item"].refs
|
||||
cancelMu.Unlock()
|
||||
if refs != 1 {
|
||||
t.Fatalf("binding a request leaked a cancellation reference: %d", refs)
|
||||
}
|
||||
cancelDownload("test-item")
|
||||
|
||||
select {
|
||||
@@ -410,7 +365,6 @@ func TestExtensionRuntime_BindDownloadCancelContextPreservesPreCancelledState(t
|
||||
runtime := newExtensionRuntime(ext)
|
||||
runtime.setActiveDownloadItemID("test-item")
|
||||
cancelDownload("test-item")
|
||||
initDownloadCancel("test-item")
|
||||
t.Cleanup(func() {
|
||||
clearDownloadCancel("test-item")
|
||||
runtime.clearActiveDownloadItemID()
|
||||
@@ -461,19 +415,12 @@ func TestExtensionRuntime_BindExtensionRequestCancelContext(t *testing.T) {
|
||||
|
||||
runtime.setActiveRequestID(requestID)
|
||||
defer runtime.clearActiveRequestID()
|
||||
initExtensionRequestCancel(requestID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req = runtime.bindDownloadCancelContext(req)
|
||||
extensionRequestCancelMu.Lock()
|
||||
refs := extensionRequestCancelMap[requestID].refs
|
||||
extensionRequestCancelMu.Unlock()
|
||||
if refs != 1 {
|
||||
t.Fatalf("binding a request leaked a cancellation reference: %d", refs)
|
||||
}
|
||||
|
||||
cancelExtensionRequest(requestID)
|
||||
select {
|
||||
@@ -519,61 +466,6 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntimeAPIsRequireDeclaredPermissions(t *testing.T) {
|
||||
withoutPermissions := &loadedExtension{
|
||||
ID: "no-permissions",
|
||||
Manifest: &ExtensionManifest{Name: "no-permissions"},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
vm := goja.New()
|
||||
newExtensionRuntime(withoutPermissions).RegisterAPIs(vm)
|
||||
for _, api := range []string{"storage", "credentials", "auth", "session", "file", "ffmpeg"} {
|
||||
if value := vm.Get(api); value != nil && !goja.IsUndefined(value) {
|
||||
t.Fatalf("%s API was exposed without permission", api)
|
||||
}
|
||||
}
|
||||
|
||||
withPermissions := &loadedExtension{
|
||||
ID: "with-permissions",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "with-permissions",
|
||||
Permissions: ExtensionPermissions{Storage: true, File: true},
|
||||
Capabilities: map[string]any{"rawFfmpeg": true},
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
vm = goja.New()
|
||||
newExtensionRuntime(withPermissions).RegisterAPIs(vm)
|
||||
for _, api := range []string{"storage", "credentials", "auth", "file", "ffmpeg"} {
|
||||
if value := vm.Get(api); value == nil || goja.IsUndefined(value) {
|
||||
t.Fatalf("%s API was not exposed with permission", api)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePostProcessResultRestrictsReplacementTargets(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
input := PostProcessInput{Path: filepath.Join(workDir, "input.flac"), URI: "content://input"}
|
||||
ext := &loadedExtension{
|
||||
ID: "post-process",
|
||||
Manifest: &ExtensionManifest{Name: "post-process"},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
if err := validatePostProcessResult(ext, input, &PostProcessResult{NewFilePath: input.Path, NewFileURI: input.URI}); err != nil {
|
||||
t.Fatalf("unchanged target should be accepted: %v", err)
|
||||
}
|
||||
if err := validatePostProcessResult(ext, input, &PostProcessResult{NewFilePath: filepath.Join(workDir, "output.flac")}); err == nil {
|
||||
t.Fatal("replacement path should require file permission")
|
||||
}
|
||||
ext.Manifest.Permissions.File = true
|
||||
if err := validatePostProcessResult(ext, input, &PostProcessResult{NewFilePath: filepath.Join(workDir, "output.flac")}); err != nil {
|
||||
t.Fatalf("sibling replacement should be accepted with file permission: %v", err)
|
||||
}
|
||||
if err := validatePostProcessResult(ext, input, &PostProcessResult{NewFileURI: "content://other"}); err == nil {
|
||||
t.Fatal("replacement URI should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPrivateIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
host string
|
||||
|
||||
@@ -11,35 +11,22 @@ import (
|
||||
)
|
||||
|
||||
type JSExecutionError struct {
|
||||
Message string
|
||||
IsTimeout bool
|
||||
RuntimeUnsafe bool
|
||||
Cause error
|
||||
Message string
|
||||
IsTimeout bool
|
||||
}
|
||||
|
||||
func (e *JSExecutionError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func (e *JSExecutionError) Unwrap() error {
|
||||
return e.Cause
|
||||
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
return RunWithTimeoutContext(context.Background(), vm, script, timeout)
|
||||
}
|
||||
|
||||
var jsInterruptGracePeriod = 5 * time.Second
|
||||
|
||||
func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
return runGojaCallWithTimeoutContext(ctx, vm, func() (goja.Value, error) {
|
||||
return vm.RunString(script)
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
func runGojaCallWithTimeoutContext(ctx context.Context, vm *goja.Runtime, call func() (goja.Value, error), timeout time.Duration) (goja.Value, error) {
|
||||
if vm == nil {
|
||||
return nil, fmt.Errorf("extension runtime unavailable")
|
||||
}
|
||||
if call == nil {
|
||||
return nil, fmt.Errorf("extension call unavailable")
|
||||
}
|
||||
|
||||
if timeout <= 0 {
|
||||
timeout = DefaultJSTimeout
|
||||
@@ -79,7 +66,7 @@ func runGojaCallWithTimeoutContext(ctx context.Context, vm *goja.Runtime, call f
|
||||
}
|
||||
}()
|
||||
|
||||
val, err := call()
|
||||
val, err := vm.RunString(script)
|
||||
resultCh <- result{val, err}
|
||||
}()
|
||||
|
||||
@@ -104,29 +91,27 @@ func runGojaCallWithTimeoutContext(ctx context.Context, vm *goja.Runtime, call f
|
||||
// caller will access the VM concurrently and crash with a nil
|
||||
// pointer dereference.
|
||||
select {
|
||||
case <-resultCh:
|
||||
case res := <-resultCh:
|
||||
if cancelled {
|
||||
return nil, ErrExtensionRequestCancelled
|
||||
}
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
return nil, &JSExecutionError{
|
||||
Message: "execution timeout exceeded",
|
||||
IsTimeout: true,
|
||||
}
|
||||
case <-time.After(jsInterruptGracePeriod):
|
||||
case <-time.After(60 * time.Second):
|
||||
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
|
||||
// Log a warning — the VM should NOT be reused after this.
|
||||
GoLog("[extensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
|
||||
message := "execution timeout exceeded (runtime quarantined)"
|
||||
var cause error
|
||||
if cancelled {
|
||||
message = "extension request cancelled (runtime quarantined)"
|
||||
cause = ErrExtensionRequestCancelled
|
||||
return nil, ErrExtensionRequestCancelled
|
||||
}
|
||||
return nil, &JSExecutionError{
|
||||
Message: message,
|
||||
IsTimeout: !cancelled,
|
||||
RuntimeUnsafe: true,
|
||||
Cause: cause,
|
||||
Message: "execution timeout exceeded (force)",
|
||||
IsTimeout: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,32 +126,13 @@ func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Dura
|
||||
func RunWithTimeoutContextAndRecover(ctx context.Context, vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
result, err := RunWithTimeoutContext(ctx, vm, script, timeout)
|
||||
|
||||
if vm != nil && !IsRuntimeUnsafeError(err) {
|
||||
if vm != nil {
|
||||
vm.ClearInterrupt()
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func runGojaCallWithTimeoutAndRecover(vm *goja.Runtime, call func() (goja.Value, error), timeout time.Duration) (goja.Value, error) {
|
||||
return runGojaCallWithTimeoutContextAndRecover(context.Background(), vm, call, timeout)
|
||||
}
|
||||
|
||||
func runGojaCallWithTimeoutContextAndRecover(ctx context.Context, vm *goja.Runtime, call func() (goja.Value, error), timeout time.Duration) (goja.Value, error) {
|
||||
result, err := runGojaCallWithTimeoutContext(ctx, vm, call, timeout)
|
||||
|
||||
if vm != nil && !IsRuntimeUnsafeError(err) {
|
||||
vm.ClearInterrupt()
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
func IsRuntimeUnsafeError(err error) bool {
|
||||
jsErr, ok := err.(*JSExecutionError)
|
||||
return ok && jsErr.RuntimeUnsafe
|
||||
}
|
||||
|
||||
func IsTimeoutError(err error) bool {
|
||||
if jsErr, ok := err.(*JSExecutionError); ok {
|
||||
return jsErr.IsTimeout
|
||||
|
||||
+18
-59
@@ -13,13 +13,11 @@ import (
|
||||
var (
|
||||
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||
multiUnderscore = regexp.MustCompile(`_+`)
|
||||
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc|playlist_position|playlistPosition|position):([0-9]+)\}`)
|
||||
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc):([0-9]+)\}`)
|
||||
dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`)
|
||||
yearPattern = regexp.MustCompile(`\d{4}`)
|
||||
)
|
||||
|
||||
const maxSanitizedFilenameBytes = 200
|
||||
|
||||
func sanitizeFilename(filename string) string {
|
||||
sanitized := strings.ReplaceAll(filename, "/", " ")
|
||||
sanitized = invalidChars.ReplaceAllString(sanitized, " ")
|
||||
@@ -49,8 +47,8 @@ func sanitizeFilename(filename string) string {
|
||||
sanitized = strings.ToValidUTF8(sanitized, "_")
|
||||
}
|
||||
|
||||
if len(sanitized) > maxSanitizedFilenameBytes {
|
||||
sanitized = truncateUTF8Bytes(sanitized, maxSanitizedFilenameBytes)
|
||||
if len(sanitized) > 200 {
|
||||
sanitized = truncateUTF8Bytes(sanitized, 200)
|
||||
sanitized = strings.TrimSpace(strings.Trim(sanitized, ". "))
|
||||
sanitized = strings.Trim(sanitized, "_ ")
|
||||
}
|
||||
@@ -62,29 +60,6 @@ func sanitizeFilename(filename string) string {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func sanitizeFilenamePreservingToken(filename string, token string) string {
|
||||
sanitized := sanitizeFilename(filename)
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" || !strings.Contains(filename, token) || strings.Contains(sanitized, token) {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
safeToken := sanitizeFilename(token)
|
||||
suffix := " - " + safeToken
|
||||
prefixLimit := maxSanitizedFilenameBytes - len(suffix)
|
||||
if prefixLimit <= 0 {
|
||||
return truncateUTF8Bytes(safeToken, maxSanitizedFilenameBytes)
|
||||
}
|
||||
rawPrefix := strings.Trim(strings.ReplaceAll(filename, token, ""), " _-")
|
||||
prefix := sanitizeFilename(rawPrefix)
|
||||
prefix = truncateUTF8Bytes(prefix, prefixLimit)
|
||||
prefix = strings.TrimSpace(strings.Trim(prefix, ". _-"))
|
||||
if prefix == "" || prefix == "Unknown" {
|
||||
return safeToken
|
||||
}
|
||||
return prefix + suffix
|
||||
}
|
||||
|
||||
func truncateUTF8Bytes(value string, maxBytes int) string {
|
||||
if maxBytes <= 0 || len(value) <= maxBytes {
|
||||
return value
|
||||
@@ -104,7 +79,7 @@ func truncateUTF8Bytes(value string, maxBytes int) string {
|
||||
return value
|
||||
}
|
||||
|
||||
func buildFilenameFromTemplate(template string, metadata map[string]any) string {
|
||||
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
|
||||
if template == "" {
|
||||
template = "{artist} - {title}"
|
||||
}
|
||||
@@ -119,22 +94,15 @@ func buildFilenameFromTemplate(template string, metadata map[string]any) string
|
||||
}
|
||||
|
||||
placeholders := map[string]string{
|
||||
"{title}": getString(metadata, "title"),
|
||||
"{artist}": getString(metadata, "artist"),
|
||||
"{album}": getString(metadata, "album"),
|
||||
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
||||
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
|
||||
"{playlist_position}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||
"{playlist position}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||
"{playlistPosition}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||
"{position}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||
"{playlist_position_raw}": formatRawNumber(getPlaylistPosition(metadata)),
|
||||
"{year}": yearValue,
|
||||
"{date}": dateValue,
|
||||
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
||||
"{disc_raw}": formatRawNumber(getInt(metadata, "disc")),
|
||||
"{quality}": getString(metadata, "quality"),
|
||||
"{quality_variant}": getString(metadata, "quality_variant"),
|
||||
"{title}": getString(metadata, "title"),
|
||||
"{artist}": getString(metadata, "artist"),
|
||||
"{album}": getString(metadata, "album"),
|
||||
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
||||
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
|
||||
"{year}": yearValue,
|
||||
"{date}": dateValue,
|
||||
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
||||
"{disc_raw}": formatRawNumber(getInt(metadata, "disc")),
|
||||
}
|
||||
|
||||
for placeholder, value := range placeholders {
|
||||
@@ -144,7 +112,7 @@ func buildFilenameFromTemplate(template string, metadata map[string]any) string
|
||||
return result
|
||||
}
|
||||
|
||||
func replaceFormattedNumberPlaceholders(template string, metadata map[string]any) string {
|
||||
func replaceFormattedNumberPlaceholders(template string, metadata map[string]interface{}) string {
|
||||
return formattedNumberPlaceholderExpr.ReplaceAllStringFunc(template, func(match string) string {
|
||||
parts := formattedNumberPlaceholderExpr.FindStringSubmatch(match)
|
||||
if len(parts) != 3 {
|
||||
@@ -152,9 +120,6 @@ func replaceFormattedNumberPlaceholders(template string, metadata map[string]any
|
||||
}
|
||||
|
||||
number := getInt(metadata, parts[1])
|
||||
if parts[1] == "playlist_position" || parts[1] == "playlistPosition" || parts[1] == "position" {
|
||||
number = getPlaylistPosition(metadata)
|
||||
}
|
||||
width, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return ""
|
||||
@@ -164,7 +129,7 @@ func replaceFormattedNumberPlaceholders(template string, metadata map[string]any
|
||||
})
|
||||
}
|
||||
|
||||
func replaceDateFormatPlaceholders(template string, metadata map[string]any) string {
|
||||
func replaceDateFormatPlaceholders(template string, metadata map[string]interface{}) string {
|
||||
return dateFormatPlaceholderExpr.ReplaceAllStringFunc(template, func(match string) string {
|
||||
parts := dateFormatPlaceholderExpr.FindStringSubmatch(match)
|
||||
if len(parts) != 2 {
|
||||
@@ -175,7 +140,7 @@ func replaceDateFormatPlaceholders(template string, metadata map[string]any) str
|
||||
})
|
||||
}
|
||||
|
||||
func getDateValue(metadata map[string]any) string {
|
||||
func getDateValue(metadata map[string]interface{}) string {
|
||||
date := getString(metadata, "date")
|
||||
if date != "" {
|
||||
return date
|
||||
@@ -189,7 +154,7 @@ func getDateValue(metadata map[string]any) string {
|
||||
return getString(metadata, "year")
|
||||
}
|
||||
|
||||
func getString(m map[string]any, key string) string {
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
switch value := v.(type) {
|
||||
case string:
|
||||
@@ -205,15 +170,13 @@ func getString(m map[string]any, key string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func getInt(m map[string]any, key string) int {
|
||||
func getInt(m map[string]interface{}, key string) int {
|
||||
candidateKeys := []string{key}
|
||||
switch key {
|
||||
case "track":
|
||||
candidateKeys = append(candidateKeys, "track_number")
|
||||
case "disc":
|
||||
candidateKeys = append(candidateKeys, "disc_number")
|
||||
case "playlist_position", "playlistPosition", "playlist position", "position":
|
||||
candidateKeys = append(candidateKeys, "playlistPosition", "playlist position", "position")
|
||||
}
|
||||
|
||||
for _, candidate := range candidateKeys {
|
||||
@@ -237,10 +200,6 @@ func getInt(m map[string]any, key string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func getPlaylistPosition(metadata map[string]any) int {
|
||||
return getInt(metadata, "playlist_position")
|
||||
}
|
||||
|
||||
func formatTrackNumber(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestBuildFilenameFromTemplate_WithRawTrackAndDisc(t *testing.T) {
|
||||
metadata := map[string]any{
|
||||
metadata := map[string]interface{}{
|
||||
"title": "Song Name",
|
||||
"artist": "Artist Name",
|
||||
"album": "Album Name",
|
||||
@@ -28,7 +28,7 @@ func TestBuildFilenameFromTemplate_WithRawTrackAndDisc(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_RawPlaceholdersEmptyWhenZero(t *testing.T) {
|
||||
metadata := map[string]any{
|
||||
metadata := map[string]interface{}{
|
||||
"title": "Song Name",
|
||||
"artist": "Artist Name",
|
||||
"track": 0,
|
||||
@@ -43,7 +43,7 @@ func TestBuildFilenameFromTemplate_RawPlaceholdersEmptyWhenZero(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_InlineNumberFormatting(t *testing.T) {
|
||||
metadata := map[string]any{
|
||||
metadata := map[string]interface{}{
|
||||
"track": 3,
|
||||
"disc": 2,
|
||||
}
|
||||
@@ -55,88 +55,8 @@ func TestBuildFilenameFromTemplate_InlineNumberFormatting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_PlaylistPositionFormatting(t *testing.T) {
|
||||
metadata := map[string]any{
|
||||
"playlist_position": 4,
|
||||
"artist": "Artist Name",
|
||||
"title": "Song Name",
|
||||
}
|
||||
|
||||
formatted := buildFilenameFromTemplate(
|
||||
"{playlist_position:02} - {artist} - {title}",
|
||||
metadata,
|
||||
)
|
||||
expected := "04 - Artist Name - Song Name"
|
||||
if formatted != expected {
|
||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_QualityVariant(t *testing.T) {
|
||||
metadata := map[string]any{
|
||||
"artist": "Artist Name",
|
||||
"title": "Song Name",
|
||||
"quality": "HI_RES_LOSSLESS",
|
||||
}
|
||||
|
||||
formatted := buildFilenameFromTemplate(
|
||||
"{artist} - {title} - {quality}",
|
||||
metadata,
|
||||
)
|
||||
if formatted != "Artist Name - Song Name - HI_RES_LOSSLESS" {
|
||||
t.Fatalf("unexpected quality filename: %q", formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_QualityVariantStagingToken(t *testing.T) {
|
||||
metadata := map[string]any{
|
||||
"artist": "Artist Name",
|
||||
"title": "Song Name",
|
||||
"quality_variant": "qv_12345678",
|
||||
}
|
||||
|
||||
formatted := buildFilenameFromTemplate(
|
||||
"{artist} - {title} - {quality_variant}",
|
||||
metadata,
|
||||
)
|
||||
if formatted != "Artist Name - Song Name - qv_12345678" {
|
||||
t.Fatalf("unexpected quality variant filename: %q", formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDownloadFilename_ProvidesRequestedQuality(t *testing.T) {
|
||||
filename := buildDownloadFilename(DownloadRequest{
|
||||
TrackName: "Song Name",
|
||||
ArtistName: "Artist Name",
|
||||
FilenameFormat: "{artist} - {title} - {quality}",
|
||||
Quality: "LOSSLESS",
|
||||
OutputExt: ".flac",
|
||||
})
|
||||
|
||||
if filename != "Artist Name - Song Name - LOSSLESS.flac" {
|
||||
t.Fatalf("unexpected download filename: %q", filename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDownloadFilename_PreservesVariantTokenWhenTruncated(t *testing.T) {
|
||||
filename := buildDownloadFilename(DownloadRequest{
|
||||
TrackName: strings.Repeat("Very Long Song ", 30),
|
||||
ArtistName: "Artist Name",
|
||||
FilenameFormat: "{artist} - {title} - {quality_variant}",
|
||||
QualityVariant: "qv_12345678",
|
||||
OutputExt: ".flac",
|
||||
})
|
||||
|
||||
if !strings.Contains(filename, "qv_12345678") {
|
||||
t.Fatalf("quality variant token was truncated: %q", filename)
|
||||
}
|
||||
if len(strings.TrimSuffix(filename, ".flac")) > maxSanitizedFilenameBytes {
|
||||
t.Fatalf("filename base exceeds limit: %d bytes", len(filename))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) {
|
||||
metadata := map[string]any{
|
||||
metadata := map[string]interface{}{
|
||||
"artist": "Artist Name",
|
||||
"title": "Song Name",
|
||||
"release_date": "2024-03-09",
|
||||
@@ -155,7 +75,7 @@ func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildFilenameFromTemplate_DateStrftimeFormattingWithYearOnly(t *testing.T) {
|
||||
metadata := map[string]any{
|
||||
metadata := map[string]interface{}{
|
||||
"artist": "Artist Name",
|
||||
"title": "Song Name",
|
||||
"date": "2019",
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
flac "github.com/go-flac/go-flac/v2"
|
||||
)
|
||||
|
||||
// saveFlacFile persists an edited FLAC crash-safely. go-flac's Save(samePath)
|
||||
// rewrites the file in place by shifting the audio body within the same inode,
|
||||
// so a process kill or power loss mid-save destroys the file with no recovery
|
||||
// copy. Instead, stream the whole edited file to a sibling temp, fsync it, and
|
||||
// rename it over the target: an interruption at any point leaves either the
|
||||
// old intact file or the new complete one.
|
||||
//
|
||||
// fd-backed targets (/proc/self/fd/N) have no directory entry to rename over,
|
||||
// so those keep the library's in-place path.
|
||||
func saveFlacFile(f *flac.File, filePath string) error {
|
||||
if strings.HasPrefix(filePath, "/proc/self/fd/") {
|
||||
return f.Save(filePath)
|
||||
}
|
||||
|
||||
// The ".partial" suffix keeps the temp invisible to library scans and
|
||||
// extension duplicate checks; the extra ".tag" avoids colliding with the
|
||||
// download staging sibling of the same final path.
|
||||
tmpPath := filePath + ".tag.partial"
|
||||
os.Remove(tmpPath)
|
||||
tmp, err := os.Create(tmpPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp tag file: %w", err)
|
||||
}
|
||||
// WriteTo streams the audio frames from the still-open source handle and
|
||||
// closes it when done, so the rename below can replace the source even on
|
||||
// Windows.
|
||||
if _, err := f.WriteTo(tmp); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("failed to sync temp tag file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, filePath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return fmt.Errorf("failed to publish tagged file: %w", err)
|
||||
}
|
||||
syncDir(filepath.Dir(filePath))
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncDir best-effort fsyncs a directory so a just-renamed entry survives
|
||||
// power loss. Unsupported on some platforms/filesystems; errors are ignored.
|
||||
func syncDir(dir string) {
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = d.Sync()
|
||||
_ = d.Close()
|
||||
}
|
||||
+16
-17
@@ -1,30 +1,29 @@
|
||||
module github.com/zarz/spotiflac_android/go_backend
|
||||
|
||||
// Needs >= 1.26.3: fixes the cgo "bulkBarrierPreWrite" crash (golang/go#46893)
|
||||
// without the 1.26.0-1.26.2 arm32 SIGSYS regression (golang/go#78936).
|
||||
// Full patch version here because gomobile's bind module inherits it.
|
||||
go 1.26.5
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.25.9
|
||||
|
||||
require (
|
||||
github.com/dop251/goja v0.0.0-20260723142020-b4aef50fa347
|
||||
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||
github.com/go-flac/go-flac/v2 v2.0.4
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/text v0.40.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/mobile v0.0.0-20260602190626-68735029466e
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/text v0.38.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.5.2 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 // indirect
|
||||
github.com/klauspost/compress v1.19.1 // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
)
|
||||
|
||||
+26
-26
@@ -1,13 +1,13 @@
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0=
|
||||
github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dop251/goja v0.0.0-20260723142020-b4aef50fa347 h1:RZr+96+PKQjn444QL1K9MtncwJ/PwfE+3TJLCYJL8es=
|
||||
github.com/dop251/goja v0.0.0-20260723142020-b4aef50fa347/go.mod h1:LiIEzozrcvNXorsG/3+ypGqdTUAqZryhzSsqi0oU/Qg=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d h1:xbM5U2EvWKkHxzEQJ2DEn20FwolWZahuTnVHr6WL3Q4=
|
||||
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||
@@ -20,10 +20,10 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw=
|
||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE=
|
||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||
@@ -32,21 +32,21 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5 h1:Mn1OzFmF0ZKX/ZayHz/UdnWHufPp1wlD9lZ5U8LRDFY=
|
||||
golang.org/x/mobile v0.0.0-20260709172247-6129f5bee9d5/go.mod h1:YX+n47s+53POxN3dx9cIGxG3hGUm/lD64hvrRJFbcSA=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mobile v0.0.0-20260602190626-68735029466e h1:YxPXu/HWDTcSSrzSX+sCltsfcNCa/ZYVG43oslMouNU=
|
||||
golang.org/x/mobile v0.0.0-20260602190626-68735029466e/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+135
-228
@@ -1,10 +1,7 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -64,27 +61,16 @@ var (
|
||||
networkCompatibilityOptions NetworkCompatibilityOptions
|
||||
)
|
||||
|
||||
var transportDialer = &net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}
|
||||
|
||||
func transportDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialWithDoHFallback(ctx, transportDialer, network, addr)
|
||||
}
|
||||
|
||||
var sharedTransport = &http.Transport{
|
||||
DialContext: transportDialContext,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
MaxConnsPerHost: 20,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
// Downloads ride this transport; some extension providers prepare the
|
||||
// file server-side before the first byte, so give TTFB more headroom
|
||||
// than the API/metadata transports. The 60s stall watchdog still bounds
|
||||
// dead transfers.
|
||||
ResponseHeaderTimeout: 120 * time.Second,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
MaxConnsPerHost: 20,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
DisableKeepAlives: false,
|
||||
ForceAttemptHTTP2: true,
|
||||
@@ -95,13 +81,15 @@ var sharedTransport = &http.Transport{
|
||||
}
|
||||
|
||||
var extensionAPITransport = &http.Transport{
|
||||
DialContext: transportDialContext,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
MaxConnsPerHost: 20,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: 45 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
DisableKeepAlives: false,
|
||||
ForceAttemptHTTP2: true,
|
||||
@@ -112,21 +100,21 @@ var extensionAPITransport = &http.Transport{
|
||||
}
|
||||
|
||||
var metadataTransport = &http.Transport{
|
||||
DialContext: transportDialContext,
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 30,
|
||||
MaxIdleConnsPerHost: 5,
|
||||
MaxConnsPerHost: 10,
|
||||
IdleConnTimeout: 60 * time.Second,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: 45 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
DisableKeepAlives: false,
|
||||
ForceAttemptHTTP2: true,
|
||||
WriteBufferSize: 32 * 1024,
|
||||
ReadBufferSize: 32 * 1024,
|
||||
// Metadata responses are JSON; transparent gzip cuts transfer size several
|
||||
// times over. Downloads stay on sharedTransport with compression disabled.
|
||||
DisableCompression: false,
|
||||
DisableCompression: true,
|
||||
TLSClientConfig: newTLSCompatibilityConfig(false),
|
||||
}
|
||||
|
||||
@@ -135,6 +123,11 @@ var sharedClient = &http.Client{
|
||||
Timeout: DefaultTimeout,
|
||||
}
|
||||
|
||||
var downloadClient = &http.Client{
|
||||
Transport: newCompatibilityTransport(sharedTransport),
|
||||
Timeout: DownloadTimeout,
|
||||
}
|
||||
|
||||
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: newCompatibilityTransport(sharedTransport),
|
||||
@@ -149,11 +142,18 @@ func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
|
||||
}
|
||||
}
|
||||
|
||||
func GetSharedClient() *http.Client {
|
||||
return sharedClient
|
||||
}
|
||||
|
||||
func GetDownloadClient() *http.Client {
|
||||
return downloadClient
|
||||
}
|
||||
|
||||
func CloseIdleConnections() {
|
||||
sharedTransport.CloseIdleConnections()
|
||||
extensionAPITransport.CloseIdleConnections()
|
||||
metadataTransport.CloseIdleConnections()
|
||||
closeUTLSIdleConnections()
|
||||
}
|
||||
|
||||
func SetNetworkCompatibilityOptions(allowHTTP, insecureTLS bool) {
|
||||
@@ -287,16 +287,14 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
|
||||
if isHardConnectivityBlock(err) {
|
||||
if CheckAndLogISPBlocking(err, reqCopy.URL.String(), "HTTP") {
|
||||
return nil, WrapErrorWithISPCheck(err, reqCopy.URL.String(), "HTTP")
|
||||
}
|
||||
|
||||
if attempt < config.MaxRetries {
|
||||
GoLog("[HTTP] Request failed (attempt %d/%d): %v, retrying in %v...\n",
|
||||
attempt+1, config.MaxRetries+1, err, delay)
|
||||
if err := sleepRetry(req.Context(), delay); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
time.Sleep(delay)
|
||||
delay = calculateNextDelay(delay, config)
|
||||
}
|
||||
continue
|
||||
@@ -315,9 +313,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
lastErr = fmt.Errorf("rate limited (429)")
|
||||
if attempt < config.MaxRetries {
|
||||
GoLog("[HTTP] Rate limited, waiting %v before retry...\n", delay)
|
||||
if err := sleepRetry(req.Context(), delay); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
time.Sleep(delay)
|
||||
delay = calculateNextDelay(delay, config)
|
||||
}
|
||||
continue
|
||||
@@ -342,23 +338,14 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
return nil, fmt.Errorf("ISP blocking detected for %s (HTTP %d) - try using VPN or change DNS", req.URL.Host, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// No blocking marker: hand the caller back a readable body in
|
||||
// place of the one consumed by the scan above.
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
resp.Body.Close()
|
||||
if retryAfter := getRetryAfterDuration(resp); retryAfter > 0 {
|
||||
delay = retryAfter
|
||||
}
|
||||
lastErr = fmt.Errorf("server error: HTTP %d", resp.StatusCode)
|
||||
if attempt < config.MaxRetries {
|
||||
GoLog("[HTTP] Server error %d, retrying in %v...\n", resp.StatusCode, delay)
|
||||
if err := sleepRetry(req.Context(), delay); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
time.Sleep(delay)
|
||||
delay = calculateNextDelay(delay, config)
|
||||
}
|
||||
continue
|
||||
@@ -370,39 +357,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
return nil, fmt.Errorf("request failed after %d retries: %w", config.MaxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
// sleepRetry waits out a retry delay, aborting early when the request context
|
||||
// is cancelled so a cancelled download never sits in a backoff sleep.
|
||||
func sleepRetry(ctx context.Context, d time.Duration) error {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// jitterFloat returns a fraction in [0,1); overridable in tests for
|
||||
// deterministic backoff assertions.
|
||||
var jitterFloat = rand.Float64
|
||||
|
||||
func calculateNextDelay(currentDelay time.Duration, config RetryConfig) time.Duration {
|
||||
nextDelay := time.Duration(float64(currentDelay) * config.BackoffFactor)
|
||||
capped := min(nextDelay, config.MaxDelay)
|
||||
// Full jitter: spread retries between InitialDelay and the capped
|
||||
// exponential ceiling to avoid synchronized thundering-herd retries.
|
||||
if capped <= config.InitialDelay {
|
||||
return capped
|
||||
}
|
||||
span := capped - config.InitialDelay
|
||||
return config.InitialDelay + time.Duration(jitterFloat()*float64(span))
|
||||
return min(nextDelay, config.MaxDelay)
|
||||
}
|
||||
|
||||
// maxRetryAfterDelay caps honored Retry-After values so a hostile or
|
||||
// misconfigured server cannot park a retry loop for an hour.
|
||||
const maxRetryAfterDelay = 2 * time.Minute
|
||||
|
||||
// Returns 0 if the header is missing or invalid so callers can keep their
|
||||
// normal exponential backoff instead of stalling for an arbitrary minute.
|
||||
func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
@@ -412,13 +371,13 @@ func getRetryAfterDuration(resp *http.Response) time.Duration {
|
||||
}
|
||||
|
||||
if seconds, err := strconv.Atoi(retryAfter); err == nil {
|
||||
return min(time.Duration(seconds)*time.Second, maxRetryAfterDelay)
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
if t, err := http.ParseTime(retryAfter); err == nil {
|
||||
duration := time.Until(t)
|
||||
if duration > 0 {
|
||||
return min(duration, maxRetryAfterDelay)
|
||||
return duration
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,6 +401,32 @@ func ReadResponseBody(resp *http.Response) ([]byte, error) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func ValidateResponse(resp *http.Response) error {
|
||||
if resp == nil {
|
||||
return fmt.Errorf("response is nil")
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func BuildErrorMessage(apiURL string, statusCode int, responsePreview string) string {
|
||||
msg := fmt.Sprintf("API %s failed", apiURL)
|
||||
if statusCode > 0 {
|
||||
msg += fmt.Sprintf(" (HTTP %d)", statusCode)
|
||||
}
|
||||
if responsePreview != "" {
|
||||
if len(responsePreview) > 100 {
|
||||
responsePreview = responsePreview[:100] + "..."
|
||||
}
|
||||
msg += fmt.Sprintf(": %s", responsePreview)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
type ISPBlockingError struct {
|
||||
Domain string
|
||||
Reason string
|
||||
@@ -452,179 +437,101 @@ func (e *ISPBlockingError) Error() string {
|
||||
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
||||
}
|
||||
|
||||
// isTransientNetworkError reports retryable transport failures such as
|
||||
// timeouts and temporary DNS errors. Permanent DNS misses are excluded.
|
||||
func isTransientNetworkError(err error) bool {
|
||||
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
return true
|
||||
}
|
||||
var netErr net.Error
|
||||
return errors.As(err, &netErr) && (netErr.Timeout() || netErr.Temporary())
|
||||
}
|
||||
|
||||
// isConnectivityFailure reports DNS, dial, timeout, TLS, or truncated transport
|
||||
// errors. Application-level API messages are excluded.
|
||||
func isConnectivityFailure(err error) bool {
|
||||
return connectivityFailureReason(err) != ""
|
||||
}
|
||||
|
||||
func connectivityFailureReason(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "Request timed out - ISP may be throttling"
|
||||
}
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return "Connection closed unexpectedly - ISP may be blocking"
|
||||
return nil
|
||||
}
|
||||
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) {
|
||||
if urlErr.Timeout() {
|
||||
return "Connection timed out - ISP may be blocking access"
|
||||
}
|
||||
if urlErr.Err != nil {
|
||||
if reason := connectivityFailureReason(urlErr.Err); reason != "" {
|
||||
return reason
|
||||
}
|
||||
}
|
||||
}
|
||||
domain := extractDomain(requestURL)
|
||||
errStr := strings.ToLower(err.Error())
|
||||
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
if dnsErr.IsNotFound || dnsErr.IsTimeout || dnsErr.IsTemporary {
|
||||
return "DNS resolution failed - domain may be blocked by ISP"
|
||||
if dnsErr.IsNotFound || dnsErr.IsTemporary {
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "DNS resolution failed - domain may be blocked by ISP",
|
||||
OriginalErr: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) {
|
||||
if opErr.Timeout() {
|
||||
return "Connection timed out - ISP may be blocking access"
|
||||
}
|
||||
var errno syscall.Errno
|
||||
if errors.As(opErr.Err, &errno) {
|
||||
switch errno {
|
||||
case syscall.ECONNREFUSED:
|
||||
return "Connection refused - port may be blocked by ISP/firewall"
|
||||
case syscall.ECONNRESET:
|
||||
return "Connection reset - ISP may be intercepting traffic"
|
||||
case syscall.ETIMEDOUT:
|
||||
return "Connection timed out - ISP may be blocking access"
|
||||
case syscall.ENETUNREACH:
|
||||
return "Network unreachable - ISP may be blocking route"
|
||||
case syscall.EHOSTUNREACH:
|
||||
return "Host unreachable - ISP may be blocking destination"
|
||||
if opErr.Op == "dial" {
|
||||
var syscallErr syscall.Errno
|
||||
if errors.As(opErr.Err, &syscallErr) {
|
||||
switch syscallErr {
|
||||
case syscall.ECONNREFUSED:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Connection refused - port may be blocked by ISP/firewall",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.ECONNRESET:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Connection reset - ISP may be intercepting traffic",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.ETIMEDOUT:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Connection timed out - ISP may be blocking access",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.ENETUNREACH:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Network unreachable - ISP may be blocking route",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.EHOSTUNREACH:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Host unreachable - ISP may be blocking destination",
|
||||
OriginalErr: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tlsErr *tls.RecordHeaderError
|
||||
if errors.As(err, &tlsErr) {
|
||||
return "TLS handshake failed - ISP may be intercepting HTTPS traffic"
|
||||
}
|
||||
|
||||
var certErr x509.CertificateInvalidError
|
||||
if errors.As(err, &certErr) {
|
||||
return "Certificate error - ISP may be using MITM proxy"
|
||||
}
|
||||
var hostnameErr x509.HostnameError
|
||||
if errors.As(err, &hostnameErr) {
|
||||
return "Certificate error - ISP may be using MITM proxy"
|
||||
}
|
||||
var unknownAuth x509.UnknownAuthorityError
|
||||
if errors.As(err, &unknownAuth) {
|
||||
return "Certificate error - ISP may be using MITM proxy"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isTLSHandshakeOrResetError reports TLS handshake/cert failures and TCP resets
|
||||
// that should trigger a Chrome fingerprint retry.
|
||||
func isTLSHandshakeOrResetError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var recordErr *tls.RecordHeaderError
|
||||
if errors.As(err, &recordErr) {
|
||||
return true
|
||||
}
|
||||
var certErr x509.CertificateInvalidError
|
||||
if errors.As(err, &certErr) {
|
||||
return true
|
||||
}
|
||||
var hostnameErr x509.HostnameError
|
||||
if errors.As(err, &hostnameErr) {
|
||||
return true
|
||||
}
|
||||
var unknownAuth x509.UnknownAuthorityError
|
||||
if errors.As(err, &unknownAuth) {
|
||||
return true
|
||||
}
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) {
|
||||
var errno syscall.Errno
|
||||
if errors.As(opErr.Err, &errno) && errno == syscall.ECONNRESET {
|
||||
return true
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic",
|
||||
OriginalErr: err,
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isHardConnectivityBlock reports transport failures that signal an active
|
||||
// block (DNS not found, connection refused/reset, TLS/cert MITM) and should
|
||||
// abort retries immediately. Timeouts and deadline-exceeded are treated as
|
||||
// transient and excluded so they fall through to normal retry backoff.
|
||||
func isHardConnectivityBlock(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
blockingPatterns := []struct {
|
||||
pattern string
|
||||
reason string
|
||||
}{
|
||||
{"connection reset by peer", "Connection reset - ISP may be intercepting traffic"},
|
||||
{"connection refused", "Connection refused - port may be blocked"},
|
||||
{"no such host", "DNS lookup failed - domain may be blocked by ISP"},
|
||||
{"i/o timeout", "Connection timed out - ISP may be blocking access"},
|
||||
{"network is unreachable", "Network unreachable - ISP may be blocking route"},
|
||||
{"tls: ", "TLS error - ISP may be intercepting HTTPS traffic"},
|
||||
{"certificate", "Certificate error - ISP may be using MITM proxy"},
|
||||
{"eof", "Connection closed unexpectedly - ISP may be blocking"},
|
||||
{"context deadline exceeded", "Request timed out - ISP may be throttling"},
|
||||
}
|
||||
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) && urlErr.Timeout() {
|
||||
return false
|
||||
}
|
||||
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
return dnsErr.IsNotFound
|
||||
}
|
||||
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) {
|
||||
if opErr.Timeout() {
|
||||
return false
|
||||
}
|
||||
var errno syscall.Errno
|
||||
if errors.As(opErr.Err, &errno) {
|
||||
switch errno {
|
||||
case syscall.ECONNREFUSED, syscall.ECONNRESET:
|
||||
return true
|
||||
for _, bp := range blockingPatterns {
|
||||
if strings.Contains(errStr, bp.pattern) {
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: bp.reason,
|
||||
OriginalErr: err,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return isTLSHandshakeOrResetError(err)
|
||||
}
|
||||
|
||||
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
reason := connectivityFailureReason(err)
|
||||
if reason == "" {
|
||||
return nil
|
||||
}
|
||||
return &ISPBlockingError{
|
||||
Domain: extractDomain(requestURL),
|
||||
Reason: reason,
|
||||
OriginalErr: err,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||
|
||||
@@ -10,8 +10,6 @@ func GetCloudflareBypassClient() *http.Client {
|
||||
return sharedClient
|
||||
}
|
||||
|
||||
func closeUTLSIdleConnections() {}
|
||||
|
||||
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", userAgentForURL(req.URL))
|
||||
resp, err := sharedClient.Do(req)
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRetryHardening(t *testing.T) {
|
||||
resp := &http.Response{Header: http.Header{"Retry-After": []string{"3600"}}}
|
||||
if d := getRetryAfterDuration(resp); d != maxRetryAfterDelay {
|
||||
t.Fatalf("Retry-After 3600s clamped to %v, want %v", d, maxRetryAfterDelay)
|
||||
}
|
||||
|
||||
// A 403 without ISP-blocking markers must reach the caller with a
|
||||
// readable body even though the marker scan consumed the original.
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: 403,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("quota exceeded")),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
got, err := DoRequestWithRetry(client, mustNewRequest(t, "https://example.com/x"), DefaultRetryConfig())
|
||||
if err != nil || got.StatusCode != 403 {
|
||||
t.Fatalf("DoRequestWithRetry = %#v/%v", got, err)
|
||||
}
|
||||
body, err := io.ReadAll(got.Body)
|
||||
got.Body.Close()
|
||||
if err != nil || string(body) != "quota exceeded" {
|
||||
t.Fatalf("403 body = %q/%v, want restored body", body, err)
|
||||
}
|
||||
|
||||
// Backoff sleeps must abort on context cancellation.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
start := time.Now()
|
||||
if err := sleepRetry(ctx, 5*time.Second); err == nil {
|
||||
t.Fatal("sleepRetry ignored cancellation")
|
||||
}
|
||||
if time.Since(start) > time.Second {
|
||||
t.Fatal("sleepRetry did not abort promptly on cancel")
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -26,6 +24,9 @@ func TestHTTPUtilityHelpers(t *testing.T) {
|
||||
if NewHTTPClientWithTimeout(time.Second).Timeout != time.Second || NewMetadataHTTPClient(time.Second).Timeout != time.Second {
|
||||
t.Fatal("client timeout mismatch")
|
||||
}
|
||||
if GetSharedClient() == nil || GetDownloadClient() == nil {
|
||||
t.Fatal("expected shared clients")
|
||||
}
|
||||
if sharedTransport.TLSClientConfig == nil || sharedTransport.TLSClientConfig.RootCAs == nil {
|
||||
t.Fatal("expected supplemental TLS root pool")
|
||||
}
|
||||
@@ -112,33 +113,33 @@ func TestHTTPUtilityHelpers(t *testing.T) {
|
||||
if body, err := ReadResponseBody(&http.Response{Body: io.NopCloser(strings.NewReader("ok"))}); err != nil || string(body) != "ok" {
|
||||
t.Fatalf("ReadResponseBody = %q/%v", body, err)
|
||||
}
|
||||
origJitter := jitterFloat
|
||||
jitterFloat = func() float64 { return 1 }
|
||||
if err := ValidateResponse(nil); err == nil {
|
||||
t.Fatal("expected nil response validation error")
|
||||
}
|
||||
if err := ValidateResponse(&http.Response{StatusCode: 404, Status: "404 Not Found"}); err == nil {
|
||||
t.Fatal("expected bad status validation error")
|
||||
}
|
||||
if err := ValidateResponse(&http.Response{StatusCode: 200}); err != nil {
|
||||
t.Fatalf("ValidateResponse: %v", err)
|
||||
}
|
||||
if msg := BuildErrorMessage("api", 500, strings.Repeat("x", 120)); !strings.Contains(msg, "...") {
|
||||
t.Fatalf("BuildErrorMessage = %q", msg)
|
||||
}
|
||||
if calculateNextDelay(10*time.Millisecond, RetryConfig{BackoffFactor: 3, MaxDelay: 20 * time.Millisecond}) != 20*time.Millisecond {
|
||||
t.Fatal("calculateNextDelay mismatch")
|
||||
}
|
||||
jitterFloat = origJitter
|
||||
if getRetryAfterDuration(&http.Response{Header: http.Header{"Retry-After": []string{"bad"}}}) != 0 {
|
||||
t.Fatal("invalid retry-after should be zero")
|
||||
}
|
||||
resetErr := &net.OpError{Op: "read", Err: syscall.ECONNRESET}
|
||||
if isp := IsISPBlocking(resetErr, "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") {
|
||||
if isp := IsISPBlocking(errors.New("connection reset by peer"), "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") {
|
||||
t.Fatalf("IsISPBlocking = %#v", isp)
|
||||
}
|
||||
timeoutErr := &net.OpError{Op: "dial", Err: syscall.ETIMEDOUT}
|
||||
if !CheckAndLogISPBlocking(timeoutErr, "https://timeout.example/x", "test") {
|
||||
if !CheckAndLogISPBlocking(errors.New("i/o timeout"), "https://timeout.example/x", "test") {
|
||||
t.Fatal("expected logged ISP blocking")
|
||||
}
|
||||
refusedErr := &net.OpError{Op: "dial", Err: syscall.ECONNREFUSED}
|
||||
if wrapped := WrapErrorWithISPCheck(refusedErr, "https://refused.example/x", "test"); wrapped == nil || !strings.Contains(wrapped.Error(), "ISP blocking") {
|
||||
if wrapped := WrapErrorWithISPCheck(errors.New("connection refused"), "https://refused.example/x", "test"); wrapped == nil || !strings.Contains(wrapped.Error(), "ISP blocking") {
|
||||
t.Fatalf("WrapErrorWithISPCheck = %v", wrapped)
|
||||
}
|
||||
if !isTransientNetworkError(context.DeadlineExceeded) || isTransientNetworkError(&net.DNSError{IsNotFound: true}) {
|
||||
t.Fatal("isTransientNetworkError mismatch")
|
||||
}
|
||||
if !isConnectivityFailure(&net.DNSError{IsNotFound: true}) || !isConnectivityFailure(context.DeadlineExceeded) {
|
||||
t.Fatal("isConnectivityFailure mismatch")
|
||||
}
|
||||
if WrapErrorWithISPCheck(nil, "", "test") != nil {
|
||||
t.Fatal("nil wrap should stay nil")
|
||||
}
|
||||
@@ -158,6 +159,9 @@ func TestRateLimiterHelpers(t *testing.T) {
|
||||
if limiter.Available() != 0 {
|
||||
t.Fatalf("available after acquire = %d", limiter.Available())
|
||||
}
|
||||
if GetSongLinkRateLimiter() == nil {
|
||||
t.Fatal("expected global limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func mustNewRequest(t *testing.T, rawURL string) *http.Request {
|
||||
|
||||
+30
-129
@@ -4,38 +4,27 @@ package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
utls "github.com/refraction-networking/utls"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
// utlsSessionCache is shared by every uTLS handshake so TLS 1.3 tickets enable
|
||||
// resumption (fewer round-trips) across requests and hosts.
|
||||
var utlsSessionCache = utls.NewLRUClientSessionCache(0)
|
||||
|
||||
// utlsTransport dials with a Chrome TLS fingerprint and pools one healthy HTTP/2
|
||||
// connection per host, re-dialing when it dies (e.g. after a network switch).
|
||||
type utlsTransport struct {
|
||||
dialer *net.Dialer
|
||||
h2 *http2.Transport
|
||||
mu sync.Mutex
|
||||
conns map[string]*http2.ClientConn
|
||||
}
|
||||
|
||||
func newUTLSTransport() *utlsTransport {
|
||||
return &utlsTransport{
|
||||
dialer: &net.Dialer{
|
||||
Timeout: 10 * Second,
|
||||
Timeout: 30 * Second,
|
||||
KeepAlive: 30 * Second,
|
||||
},
|
||||
h2: &http2.Transport{},
|
||||
conns: make(map[string]*http2.ClientConn),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,142 +34,48 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
}
|
||||
|
||||
host := req.URL.Hostname()
|
||||
addr := net.JoinHostPort(host, t.getPort(req.URL))
|
||||
port := t.getPort(req.URL)
|
||||
addr := net.JoinHostPort(host, port)
|
||||
|
||||
if cc := t.cachedConn(addr); cc != nil {
|
||||
resp, err := cc.RoundTrip(req)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
// A pooled conn can be silently dead after a network switch. Drop it
|
||||
// and, when the request is safely repeatable, fall through to a fresh
|
||||
// dial instead of failing where the old dial-per-request code would
|
||||
// have succeeded.
|
||||
t.invalidate(addr, cc)
|
||||
retryReq, ok := rewindRequestBody(req)
|
||||
if !ok {
|
||||
return nil, err
|
||||
}
|
||||
req = retryReq
|
||||
}
|
||||
|
||||
tlsConn, proto, err := t.dial(req.Context(), host, addr)
|
||||
conn, err := t.dialer.DialContext(req.Context(), "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if proto != "h2" {
|
||||
// HTTP/1.1: single-use conn closed once the body is drained.
|
||||
transport := &http.Transport{
|
||||
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return tlsConn, nil
|
||||
},
|
||||
DisableKeepAlives: true,
|
||||
}
|
||||
return transport.RoundTrip(req)
|
||||
}
|
||||
|
||||
cc, err := t.h2.NewClientConn(tlsConn)
|
||||
if err != nil {
|
||||
tlsConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
cc = t.storeConn(addr, cc)
|
||||
return cc.RoundTrip(req)
|
||||
}
|
||||
|
||||
// rewindRequestBody returns a request whose body can be sent again after a
|
||||
// failed attempt on a pooled connection: bodyless requests as-is, requests
|
||||
// with GetBody with a rebuilt body, anything else not ok.
|
||||
func rewindRequestBody(req *http.Request) (*http.Request, bool) {
|
||||
if req.Body == nil {
|
||||
return req, true
|
||||
}
|
||||
if req.GetBody == nil {
|
||||
return nil, false
|
||||
}
|
||||
body, err := req.GetBody()
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
retryReq := req.Clone(req.Context())
|
||||
retryReq.Body = body
|
||||
return retryReq, true
|
||||
}
|
||||
|
||||
// dial opens a TCP connection and completes the Chrome-fingerprint TLS handshake,
|
||||
// returning the connection and negotiated ALPN protocol.
|
||||
func (t *utlsTransport) dial(ctx context.Context, host, addr string) (*utls.UConn, string, error) {
|
||||
conn, err := dialWithDoHFallback(ctx, t.dialer, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
opts := GetNetworkCompatibilityOptions()
|
||||
tlsConn := utls.UClient(conn, &utls.Config{
|
||||
RootCAs: supplementalRootCAs(),
|
||||
InsecureSkipVerify: opts.InsecureTLS,
|
||||
ServerName: host,
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
ClientSessionCache: utlsSessionCache,
|
||||
}, utls.HelloChrome_Auto)
|
||||
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
conn.Close()
|
||||
return nil, "", err
|
||||
return nil, err
|
||||
}
|
||||
return tlsConn, tlsConn.ConnectionState().NegotiatedProtocol, nil
|
||||
}
|
||||
|
||||
func (t *utlsTransport) cachedConn(addr string) *http2.ClientConn {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if cc := t.conns[addr]; cc != nil && cc.CanTakeNewRequest() {
|
||||
return cc
|
||||
negotiatedProto := tlsConn.ConnectionState().NegotiatedProtocol
|
||||
|
||||
if negotiatedProto == "h2" {
|
||||
h2Transport := &http2.Transport{
|
||||
DialTLSContext: func(ctx context.Context, network, addr string, cfg *tls.Config) (net.Conn, error) {
|
||||
return tlsConn, nil
|
||||
},
|
||||
AllowHTTP: false,
|
||||
DisableCompression: false,
|
||||
}
|
||||
return h2Transport.RoundTrip(req)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *utlsTransport) invalidate(addr string, cc *http2.ClientConn) {
|
||||
t.mu.Lock()
|
||||
if t.conns[addr] == cc {
|
||||
delete(t.conns, addr)
|
||||
transport := &http.Transport{
|
||||
DialTLSContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return tlsConn, nil
|
||||
},
|
||||
DisableKeepAlives: true,
|
||||
}
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// storeConn caches cc, but if a concurrent dial already cached a healthy conn for
|
||||
// addr it discards the freshly built cc (no in-flight requests) and returns the
|
||||
// existing one, avoiding a leaked connection.
|
||||
func (t *utlsTransport) storeConn(addr string, cc *http2.ClientConn) *http2.ClientConn {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if existing := t.conns[addr]; existing != nil && existing.CanTakeNewRequest() {
|
||||
cc.Close()
|
||||
return existing
|
||||
}
|
||||
t.conns[addr] = cc
|
||||
return cc
|
||||
}
|
||||
|
||||
// closeIdleConnections drops every pooled conn so the next request re-dials —
|
||||
// needed after a network switch, where pooled conns are silently dead and the
|
||||
// first request would otherwise hang on one until its timeout. Conns are shut
|
||||
// down gracefully so in-flight streams finish (or fail) before the close.
|
||||
func (t *utlsTransport) closeIdleConnections() {
|
||||
t.mu.Lock()
|
||||
conns := t.conns
|
||||
t.conns = make(map[string]*http2.ClientConn)
|
||||
t.mu.Unlock()
|
||||
for _, cc := range conns {
|
||||
go cc.Shutdown(context.Background())
|
||||
}
|
||||
}
|
||||
|
||||
// closeUTLSIdleConnections lets platform-neutral code (CloseIdleConnections)
|
||||
// reach the uTLS pool; the ios build provides a no-op stub.
|
||||
func closeUTLSIdleConnections() {
|
||||
cloudflareBypassTransport.closeIdleConnections()
|
||||
return transport.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (t *utlsTransport) getPort(u *url.URL) string {
|
||||
@@ -249,7 +144,13 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if isTLSHandshakeOrResetError(err) {
|
||||
errStr := strings.ToLower(err.Error())
|
||||
tlsRelated := strings.Contains(errStr, "tls") ||
|
||||
strings.Contains(errStr, "handshake") ||
|
||||
strings.Contains(errStr, "certificate") ||
|
||||
strings.Contains(errStr, "connection reset")
|
||||
|
||||
if tlsRelated {
|
||||
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
||||
|
||||
reqCopy := req.Clone(req.Context())
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// marshalJSONString marshals v and returns it as a string, the shape every
|
||||
// gomobile export returns.
|
||||
func marshalJSONString(v any) (string, error) {
|
||||
jsonBytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
+89
-295
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -86,7 +85,6 @@ var supportedAudioFormats = map[string]bool{
|
||||
type libraryAudioFileInfo struct {
|
||||
path string
|
||||
modTime int64
|
||||
size int64
|
||||
}
|
||||
|
||||
type scannedCueFileInfo struct {
|
||||
@@ -94,18 +92,6 @@ type scannedCueFileInfo struct {
|
||||
audioPath string
|
||||
}
|
||||
|
||||
type libraryScanTask struct {
|
||||
index int
|
||||
info libraryAudioFileInfo
|
||||
}
|
||||
|
||||
type libraryScanTaskResult struct {
|
||||
index int
|
||||
path string
|
||||
results []LibraryScanResult
|
||||
err error
|
||||
}
|
||||
|
||||
func isLibraryStagingFile(path string) bool {
|
||||
name := strings.ToLower(filepath.Base(path))
|
||||
if strings.HasSuffix(name, ".partial") {
|
||||
@@ -153,7 +139,6 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
|
||||
files = append(files, libraryAudioFileInfo{
|
||||
path: path,
|
||||
modTime: info.ModTime().UnixMilli(),
|
||||
size: info.Size(),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
@@ -165,145 +150,6 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func libraryAudioCoverCacheKey(info libraryAudioFileInfo) string {
|
||||
return fmt.Sprintf("%s|%d|%d", info.path, info.size, info.modTime)
|
||||
}
|
||||
|
||||
func libraryScanWorkerCount(taskCount int) int {
|
||||
if taskCount < 16 {
|
||||
return 1
|
||||
}
|
||||
workers := runtime.NumCPU()
|
||||
if workers > 4 {
|
||||
workers = 4
|
||||
}
|
||||
if workers < 2 {
|
||||
workers = 2
|
||||
}
|
||||
if workers > taskCount {
|
||||
workers = taskCount
|
||||
}
|
||||
return workers
|
||||
}
|
||||
|
||||
func updateLibraryScanProgress(scannedFiles, totalFiles int, currentPath string) {
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ScannedFiles = scannedFiles
|
||||
libraryScanProgress.CurrentFile = filepath.Base(currentPath)
|
||||
if totalFiles > 0 {
|
||||
libraryScanProgress.ProgressPct = float64(scannedFiles) / float64(totalFiles) * 100
|
||||
}
|
||||
libraryScanProgressMu.Unlock()
|
||||
}
|
||||
|
||||
func scanLibraryAudioTasksParallel(tasks []libraryScanTask, scanTime string, cancelCh <-chan struct{}, totalFiles int, completed *int) (map[int][]LibraryScanResult, int, error) {
|
||||
resultsByIndex := make(map[int][]LibraryScanResult, len(tasks))
|
||||
if len(tasks) == 0 {
|
||||
return resultsByIndex, 0, nil
|
||||
}
|
||||
|
||||
workers := libraryScanWorkerCount(len(tasks))
|
||||
if workers <= 1 {
|
||||
errorCount := 0
|
||||
for _, task := range tasks {
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return resultsByIndex, errorCount, fmt.Errorf("scan cancelled")
|
||||
default:
|
||||
}
|
||||
result, err := scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(
|
||||
task.info.path,
|
||||
"",
|
||||
libraryAudioCoverCacheKey(task.info),
|
||||
scanTime,
|
||||
task.info.modTime,
|
||||
)
|
||||
*completed++
|
||||
updateLibraryScanProgress(*completed, totalFiles, task.info.path)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning %s: %v\n", task.info.path, err)
|
||||
continue
|
||||
}
|
||||
resultsByIndex[task.index] = []LibraryScanResult{*result}
|
||||
}
|
||||
return resultsByIndex, errorCount, nil
|
||||
}
|
||||
|
||||
taskCh := make(chan libraryScanTask)
|
||||
resultCh := make(chan libraryScanTaskResult, workers)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for task := range taskCh {
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
result, err := scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(
|
||||
task.info.path,
|
||||
"",
|
||||
libraryAudioCoverCacheKey(task.info),
|
||||
scanTime,
|
||||
task.info.modTime,
|
||||
)
|
||||
taskResult := libraryScanTaskResult{
|
||||
index: task.index,
|
||||
path: task.info.path,
|
||||
err: err,
|
||||
}
|
||||
if err == nil && result != nil {
|
||||
taskResult.results = []LibraryScanResult{*result}
|
||||
}
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return
|
||||
case resultCh <- taskResult:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(taskCh)
|
||||
for _, task := range tasks {
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return
|
||||
case taskCh <- task:
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resultCh)
|
||||
}()
|
||||
|
||||
errorCount := 0
|
||||
for taskResult := range resultCh {
|
||||
*completed++
|
||||
updateLibraryScanProgress(*completed, totalFiles, taskResult.path)
|
||||
if taskResult.err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning %s: %v\n", taskResult.path, taskResult.err)
|
||||
continue
|
||||
}
|
||||
resultsByIndex[taskResult.index] = taskResult.results
|
||||
}
|
||||
|
||||
select {
|
||||
case <-cancelCh:
|
||||
return resultsByIndex, errorCount, fmt.Errorf("scan cancelled")
|
||||
default:
|
||||
}
|
||||
return resultsByIndex, errorCount, nil
|
||||
}
|
||||
|
||||
func SetLibraryCoverCacheDir(cacheDir string) {
|
||||
libraryCoverCacheMu.Lock()
|
||||
libraryCoverCacheDir = cacheDir
|
||||
@@ -379,10 +225,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
resultsByIndex := make(map[int][]LibraryScanResult, totalFiles)
|
||||
audioTasks := make([]libraryScanTask, 0, totalFiles)
|
||||
completedFiles := 0
|
||||
|
||||
for i, fileInfo := range audioFileInfos {
|
||||
filePath := fileInfo.path
|
||||
select {
|
||||
@@ -391,6 +233,12 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
default:
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ScannedFiles = i + 1
|
||||
libraryScanProgress.CurrentFile = filepath.Base(filePath)
|
||||
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
if ext == ".cue" {
|
||||
@@ -412,44 +260,26 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
|
||||
completedFiles++
|
||||
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
|
||||
continue
|
||||
}
|
||||
resultsByIndex[i] = cueResults
|
||||
completedFiles++
|
||||
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
|
||||
results = append(results, cueResults...)
|
||||
GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults))
|
||||
continue
|
||||
}
|
||||
|
||||
if cueReferencedAudioFiles[filePath] {
|
||||
completedFiles++
|
||||
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
|
||||
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
||||
continue
|
||||
}
|
||||
|
||||
audioTasks = append(audioTasks, libraryScanTask{index: i, info: fileInfo})
|
||||
}
|
||||
result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
audioResults, audioErrors, err := scanLibraryAudioTasksParallel(
|
||||
audioTasks,
|
||||
scanTime,
|
||||
cancelCh,
|
||||
totalFiles,
|
||||
&completedFiles,
|
||||
)
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
errorCount += audioErrors
|
||||
for index, scanResults := range audioResults {
|
||||
resultsByIndex[index] = scanResults
|
||||
}
|
||||
|
||||
for i := range audioFileInfos {
|
||||
results = append(results, resultsByIndex[i]...)
|
||||
results = append(results, *result)
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
@@ -490,12 +320,6 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
|
||||
libraryCoverCacheMu.RLock()
|
||||
coverCacheDir := libraryCoverCacheDir
|
||||
libraryCoverCacheMu.RUnlock()
|
||||
if ext == ".flac" {
|
||||
return scanFLACFileWithCoverCache(filePath, result, displayNameHint, coverCacheDir, coverCacheKey)
|
||||
}
|
||||
if ext == ".m4a" || ext == ".mp4" || ext == ".aac" {
|
||||
return scanM4AFileWithCoverCache(filePath, result, displayNameHint, coverCacheDir, coverCacheKey)
|
||||
}
|
||||
if coverCacheDir != "" {
|
||||
coverPath, err := SaveCoverToCacheWithHintAndKey(
|
||||
filePath,
|
||||
@@ -509,6 +333,10 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
|
||||
}
|
||||
|
||||
switch ext {
|
||||
case ".flac":
|
||||
return scanFLACFile(filePath, result, displayNameHint)
|
||||
case ".m4a", ".mp4", ".aac":
|
||||
return scanM4AFile(filePath, result, displayNameHint)
|
||||
case ".mp3":
|
||||
return scanMP3File(filePath, result, displayNameHint)
|
||||
case ".opus", ".ogg":
|
||||
@@ -524,29 +352,6 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
|
||||
}
|
||||
}
|
||||
|
||||
func embeddedCoverMIME(data []byte) string {
|
||||
if len(data) >= 8 &&
|
||||
data[0] == 0x89 &&
|
||||
data[1] == 0x50 &&
|
||||
data[2] == 0x4e &&
|
||||
data[3] == 0x47 {
|
||||
return "image/png"
|
||||
}
|
||||
return "image/jpeg"
|
||||
}
|
||||
|
||||
func cacheScannedCover(filePath, cacheDir, coverCacheKey string, coverData []byte) string {
|
||||
if cacheDir == "" || len(coverData) == 0 {
|
||||
return ""
|
||||
}
|
||||
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
|
||||
path, err := saveLibraryCoverDataToCache(cacheDir, cacheKey, coverData, embeddedCoverMIME(coverData))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func resolveLibraryAudioExt(filePath, displayNameHint string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
if ext != "" {
|
||||
@@ -576,16 +381,10 @@ func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *Libra
|
||||
}
|
||||
|
||||
func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
return scanFLACFileWithCoverCache(filePath, result, displayNameHint, "", "")
|
||||
}
|
||||
|
||||
func scanFLACFileWithCoverCache(filePath string, result *LibraryScanResult, displayNameHint, coverCacheDir, coverCacheKey string) (*LibraryScanResult, error) {
|
||||
f, err := parseFlacFile(filePath)
|
||||
metadata, err := ReadMetadata(filePath)
|
||||
if err != nil {
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
defer f.Close()
|
||||
metadata := metadataFromParsedFlac(f)
|
||||
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
@@ -602,7 +401,7 @@ func scanFLACFileWithCoverCache(filePath string, result *LibraryScanResult, disp
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
|
||||
quality, err := audioQualityFromParsedFlac(f)
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
@@ -610,14 +409,6 @@ func scanFLACFileWithCoverCache(filePath string, result *LibraryScanResult, disp
|
||||
result.Duration = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||
}
|
||||
}
|
||||
if coverCacheDir != "" {
|
||||
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
|
||||
if existing := existingLibraryCoverCachePath(coverCacheDir, cacheKey); existing != "" {
|
||||
result.CoverPath = existing
|
||||
} else if coverData, coverErr := coverArtFromParsedFlac(f); coverErr == nil {
|
||||
result.CoverPath = cacheScannedCover(filePath, coverCacheDir, cacheKey, coverData)
|
||||
}
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
|
||||
@@ -625,37 +416,32 @@ func scanFLACFileWithCoverCache(filePath string, result *LibraryScanResult, disp
|
||||
}
|
||||
|
||||
func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
return scanM4AFileWithCoverCache(filePath, result, displayNameHint, "", "")
|
||||
}
|
||||
|
||||
func scanM4AFileWithCoverCache(filePath string, result *LibraryScanResult, displayNameHint, coverCacheDir, coverCacheKey string) (*LibraryScanResult, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
fileSize := info.Size()
|
||||
|
||||
var metadata *AudioMetadata
|
||||
ilst, ilstErr := findM4AIlstAtom(f, fileSize)
|
||||
if ilstErr == nil {
|
||||
metadata, err = readM4ATagsFromIlst(f, fileSize, ilst)
|
||||
} else {
|
||||
err = ilstErr
|
||||
}
|
||||
metadata, err := ReadM4ATags(filePath)
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
|
||||
}
|
||||
|
||||
if metadata != nil {
|
||||
applyAudioMetadataToScan(metadata, result)
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
result.ReleaseDate = metadata.Date
|
||||
if result.ReleaseDate == "" {
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.Genre = metadata.Genre
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
}
|
||||
|
||||
quality, err := m4aQualityFromFile(f, fileSize)
|
||||
quality, err := GetM4AQuality(filePath)
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
@@ -670,16 +456,6 @@ func scanM4AFileWithCoverCache(filePath string, result *LibraryScanResult, displ
|
||||
}
|
||||
}
|
||||
}
|
||||
if coverCacheDir != "" {
|
||||
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
|
||||
if existing := existingLibraryCoverCachePath(coverCacheDir, cacheKey); existing != "" {
|
||||
result.CoverPath = existing
|
||||
} else if ilstErr == nil {
|
||||
if coverData, coverErr := extractCoverFromM4AIlst(f, fileSize, ilst); coverErr == nil {
|
||||
result.CoverPath = cacheScannedCover(filePath, coverCacheDir, cacheKey, coverData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if metadata == nil {
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
@@ -724,7 +500,24 @@ func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
|
||||
applyAudioMetadataToScan(metadata, result)
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
result.Genre = metadata.Genre
|
||||
if metadata.Date != "" {
|
||||
result.ReleaseDate = metadata.Date
|
||||
} else {
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.ISRC = metadata.ISRC
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
|
||||
quality, err := GetMP3Quality(filePath)
|
||||
if err == nil {
|
||||
@@ -790,7 +583,24 @@ func scanAPEFile(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
|
||||
applyAudioMetadataToScan(metadata, result)
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
result.Genre = metadata.Genre
|
||||
if metadata.Date != "" {
|
||||
result.ReleaseDate = metadata.Date
|
||||
} else {
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
|
||||
@@ -1064,10 +874,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
||||
}
|
||||
}
|
||||
|
||||
resultsByIndex := make(map[int][]LibraryScanResult, len(filesToScan))
|
||||
audioTasks := make([]libraryScanTask, 0, len(filesToScan))
|
||||
completedFiles := skippedCount
|
||||
|
||||
for i, f := range filesToScan {
|
||||
select {
|
||||
case <-cancelCh:
|
||||
@@ -1075,6 +881,12 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
||||
default:
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ScannedFiles = skippedCount + i + 1
|
||||
libraryScanProgress.CurrentFile = filepath.Base(f.path)
|
||||
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(f.path))
|
||||
|
||||
if ext == ".cue" {
|
||||
@@ -1096,42 +908,24 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
|
||||
completedFiles++
|
||||
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
|
||||
continue
|
||||
}
|
||||
resultsByIndex[i] = cueResults
|
||||
completedFiles++
|
||||
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
|
||||
results = append(results, cueResults...)
|
||||
continue
|
||||
}
|
||||
|
||||
if cueReferencedAudioFilesInc[f.path] {
|
||||
completedFiles++
|
||||
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
|
||||
continue
|
||||
}
|
||||
|
||||
audioTasks = append(audioTasks, libraryScanTask{index: i, info: f})
|
||||
}
|
||||
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
|
||||
continue
|
||||
}
|
||||
|
||||
audioResults, audioErrors, err := scanLibraryAudioTasksParallel(
|
||||
audioTasks,
|
||||
scanTime,
|
||||
cancelCh,
|
||||
totalFiles,
|
||||
&completedFiles,
|
||||
)
|
||||
if err != nil {
|
||||
return "{}", err
|
||||
}
|
||||
errorCount += audioErrors
|
||||
for index, scanResults := range audioResults {
|
||||
resultsByIndex[index] = scanResults
|
||||
}
|
||||
|
||||
for i := range filesToScan {
|
||||
results = append(results, resultsByIndex[i]...)
|
||||
results = append(results, *result)
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user