Compare commits

..
38 Commits
Author SHA1 Message Date
Zarz Eleutherius 1b4dfa7580 New translations app_en.arb (Spanish)
[ci skip]
2026-07-28 09:13:54 +07:00
Zarz Eleutherius 9b35c6dc42 New translations app_en.arb (Korean)
[ci skip]
2026-07-26 05:38:10 +07:00
Zarz Eleutherius 1c95109b7b New translations app_en.arb (Korean)
[ci skip]
2026-07-26 04:15:24 +07:00
Zarz Eleutherius d4c38b7550 New translations app_en.arb (Korean)
[ci skip]
2026-07-25 18:41:06 +07:00
Zarz Eleutherius c681b891eb New translations app_en.arb (Korean)
[ci skip]
2026-07-25 17:44:57 +07:00
Zarz Eleutherius c19477a4f4 New translations app_en.arb (Korean)
[ci skip]
2026-07-25 16:41:28 +07:00
Zarz Eleutherius a7069a8f91 New translations app_en.arb (Korean)
[ci skip]
2026-07-25 15:34:01 +07:00
Zarz Eleutherius bb127d1cc2 New translations app_en.arb (French)
[ci skip]
2026-07-25 14:00:34 +07:00
Zarz Eleutherius 927ca6e4ca New translations app_en.arb (Korean)
[ci skip]
2026-07-25 14:00:32 +07:00
Zarz Eleutherius 66cf998fb2 New translations app_en.arb (Korean)
[ci skip]
2026-07-25 12:55:14 +07:00
Zarz Eleutherius 37db310157 New translations app_en.arb (Hindi)
[ci skip]
2026-07-25 10:13:00 +07:00
Zarz Eleutherius 13fe8b4593 New translations app_en.arb (Chinese Traditional)
[ci skip]
2026-07-25 10:12:58 +07:00
Zarz Eleutherius a5f96b57e6 New translations app_en.arb (Chinese Simplified)
[ci skip]
2026-07-25 10:12:56 +07:00
Zarz Eleutherius f0a7a043ec New translations app_en.arb (Ukrainian)
[ci skip]
2026-07-25 10:12:55 +07:00
Zarz Eleutherius 58249d6a13 New translations app_en.arb (Turkish)
[ci skip]
2026-07-25 10:12:53 +07:00
Zarz Eleutherius d9dc7ddf3a New translations app_en.arb (Russian)
[ci skip]
2026-07-25 10:12:52 +07:00
Zarz Eleutherius cc1ad1552c New translations app_en.arb (Dutch)
[ci skip]
2026-07-25 10:12:50 +07:00
Zarz Eleutherius a3feb39e20 New translations app_en.arb (Japanese)
[ci skip]
2026-07-25 10:12:49 +07:00
Zarz Eleutherius 7676579659 New translations app_en.arb (German)
[ci skip]
2026-07-25 10:12:47 +07:00
Zarz Eleutherius bcb3729fa9 New translations app_en.arb (Arabic)
[ci skip]
2026-07-25 10:12:46 +07:00
Zarz Eleutherius 49a824c171 New translations app_en.arb (Spanish)
[ci skip]
2026-07-25 10:12:44 +07:00
Zarz Eleutherius 2a050f113b New translations app_en.arb (French)
[ci skip]
2026-07-25 10:12:43 +07:00
Zarz Eleutherius e4b99dd44b New translations app_en.arb (Indonesian)
[ci skip]
2026-07-25 10:12:41 +07:00
Zarz Eleutherius 18584ab81f New translations app_en.arb (Portuguese)
[ci skip]
2026-07-25 10:12:39 +07:00
Zarz Eleutherius a4f7360336 New translations app_en.arb (Korean)
[ci skip]
2026-07-25 10:12:38 +07:00
Zarz Eleutherius 16b44d2d3a New translations app_en.arb (Indonesian)
[ci skip]
2026-07-20 16:58:51 +07:00
Zarz Eleutherius 71c08501e7 New translations app_en.arb (Portuguese)
[ci skip]
2026-07-20 04:45:34 +07:00
Zarz Eleutherius 297cddf31d New translations app_en.arb (Portuguese)
[ci skip]
2026-07-20 03:46:10 +07:00
Zarz Eleutherius 57eef4311f New translations app_en.arb (Indonesian)
[ci skip]
2026-07-20 02:32:54 +07:00
Zarz Eleutherius b5e5fb5cd2 New translations app_en.arb (Korean)
[ci skip]
2026-07-18 20:10:22 +07:00
Zarz Eleutherius 9a9152f7f8 New translations app_en.arb (Korean)
[ci skip]
2026-07-13 00:27:02 +07:00
Zarz Eleutherius d9ceb8ac78 New translations app_en.arb (Korean)
[ci skip]
2026-07-12 22:54:50 +07:00
Zarz Eleutherius d6726f976a New translations app_en.arb (Korean)
[ci skip]
2026-07-11 20:36:39 +07:00
Zarz Eleutherius bbfc724ceb New translations app_en.arb (German)
[ci skip]
2026-07-10 19:53:11 +07:00
Zarz Eleutherius 7d3a6f9780 New translations app_en.arb (German)
[ci skip]
2026-07-10 18:47:47 +07:00
Zarz Eleutherius 19fccde3a3 New translations app_en.arb (Korean)
[ci skip]
2026-07-10 17:24:34 +07:00
Zarz Eleutherius 2cc2ac6efd New translations app_en.arb (Korean)
[ci skip]
2026-07-10 16:26:59 +07:00
Zarz Eleutherius d1ede223cf New translations app_en.arb (Korean)
[ci skip]
2026-07-10 14:04:22 +07:00
352 changed files with 111535 additions and 125177 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
"flutter": "3.44.8"
"flutter": "3.41.5"
}
-27
View File
@@ -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. -->
-185
View File
@@ -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
+19 -46
View File
@@ -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
![arm64](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm64.apk?style=flat-square&logo=android&label=arm64&color=3DDC84) ![arm32](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-arm32.apk?style=flat-square&logo=android&label=arm32&color=3DDC84) ![iOS](https://img.shields.io/github/downloads/${REPO_OWNER}/${REPO_NAME}/${VERSION}/SpotiFLAC-${VERSION}-ios-unsigned.ipa?style=flat-square&logo=apple&label=iOS&color=0078D6)
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
View File
@@ -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
View File
@@ -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! 💚
+1 -1
View File
@@ -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.
---
+7 -14
View File
@@ -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")
}
-31
View File
@@ -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,
),
)
}
}
+3 -3
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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")
+4 -4
View File
@@ -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
}
]
}
-408
View File
@@ -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)
}
-76
View File
@@ -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")
}
}
-201
View File
@@ -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
}
+5 -3
View File
@@ -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
View File
@@ -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
}
+23 -13
View File
@@ -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")
}
}
-40
View File
@@ -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
View File
@@ -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
}
-96
View File
@@ -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)
}
}
+1 -2
View File
@@ -73,8 +73,7 @@ function track(id) {
genre: "Pop",
composer: "Composer",
audioQuality: "FLAC 24-bit",
audioModes: "DOLBY_ATMOS",
explicit: true
audioModes: "DOLBY_ATMOS"
};
}
+2 -2
View File
@@ -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 ""
}
+4 -4
View File
@@ -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}",
},
},
+1 -97
View File
@@ -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
View File
@@ -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)
-237
View File
@@ -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)}
}
-33
View File
@@ -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"
}
-129
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-214
View File
@@ -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)
}
-493
View File
@@ -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
-33
View File
@@ -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)
}
-191
View File
@@ -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)
}
-572
View File
@@ -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
}
-202
View File
@@ -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
}
-785
View File
@@ -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
}
-148
View File
@@ -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")
}
+39 -109
View File
@@ -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)
}
+2 -39
View File
@@ -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",
-57
View File
@@ -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)
}
}
-647
View File
@@ -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
}
+22 -97
View File
@@ -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
View File
@@ -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)
}
+24 -92
View File
@@ -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 {
-201
View File
@@ -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)
-209
View File
@@ -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)
}
-978
View File
@@ -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
})
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -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
View File
@@ -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)
+85 -37
View File
@@ -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,
+66 -34
View File
@@ -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,
})
+33 -37
View File
@@ -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)
}
}
+324 -134
View File
@@ -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)
}
+45 -18
View File
@@ -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)
}
}
+243 -143
View File
@@ -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)
}
+7 -75
View File
@@ -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
+69 -383
View File
@@ -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)
}
}
+52 -49
View File
@@ -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(),
+13 -13
View File
@@ -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()
-714
View File
@@ -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()
}
-930
View File
@@ -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, &registry); err != nil {
return nil, fmt.Errorf("failed to parse registry: %w", err)
}
s.cache = registry
s.cache = &registry
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, &registry); 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 &registry, 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 := &registry.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
}
-108
View File
@@ -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
+14 -48
View File
@@ -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
View File
@@ -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 ""
+5 -85
View File
@@ -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",
-69
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
-2
View File
@@ -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)
-51
View File
@@ -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")
}
}
+22 -18
View File
@@ -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
View File
@@ -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())
-13
View File
@@ -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
View File
@@ -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