Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bbcda3320 | |||
| a7622676dd | |||
| 5779f910a2 | |||
| 030f44a444 | |||
| 1248270fb4 | |||
| 413e3b0686 | |||
| ac711efadc | |||
| 59f2fe880a | |||
| 355f2eba2a | |||
| f2f45fa31d | |||
| 042937a8ed | |||
| 674e9af3d0 | |||
| 76d50fab3a | |||
| 81e25d7dab | |||
| 26f26f792a | |||
| 4dfa76b49e | |||
| f511f30ad0 | |||
| a1aa1319ce | |||
| c936bd7dd0 | |||
| 3a60ea2f4e | |||
| 7dba938299 | |||
| 93e77aeb84 | |||
| dd750b95ca | |||
| e42e44f28b | |||
| 67daefdf60 | |||
| fabaf0a3ff | |||
| fb90c73f42 | |||
| c6cf65f075 | |||
| 25de009ebc | |||
| 8918d74bb5 | |||
| f9de8d45d9 | |||
| 48eef0853d | |||
| fc70a912bf | |||
| cd3e5b4b28 | |||
| 482ca82eb4 | |||
| 6d87ae5484 | |||
| bd3e2b999b | |||
| 186196e12b |
@@ -66,7 +66,7 @@ jobs:
|
|||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
java-version: "25"
|
java-version: "17"
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
@@ -164,18 +164,13 @@ jobs:
|
|||||||
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
||||||
|
|
||||||
build-ios:
|
build-ios:
|
||||||
runs-on: macos-15
|
runs-on: macos-latest
|
||||||
needs: get-version # Only depends on version, NOT android build!
|
needs: get-version # Only depends on version, NOT android build!
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Select Xcode 26.1.1
|
|
||||||
run: |
|
|
||||||
sudo xcode-select -s /Applications/Xcode_26.1.1.app
|
|
||||||
xcodebuild -version
|
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
@@ -257,15 +252,6 @@ jobs:
|
|||||||
- name: Get Flutter dependencies
|
- name: Get Flutter dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
- name: Normalize ffmpeg plugin shell scripts (strip CRLF)
|
|
||||||
run: |
|
|
||||||
find "$HOME/.pub-cache/hosted" -path "*ffmpeg_kit_flutter_new_full*/scripts/*.sh" -type f -print0 |
|
|
||||||
while IFS= read -r -d '' f; do
|
|
||||||
perl -pi -e 's/\r$//' "$f"
|
|
||||||
chmod +x "$f"
|
|
||||||
echo "Normalized line endings: $f"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Generate app icons
|
- name: Generate app icons
|
||||||
run: dart run flutter_launcher_icons
|
run: dart run flutter_launcher_icons
|
||||||
|
|
||||||
@@ -388,6 +374,8 @@ jobs:
|
|||||||
### Installation
|
### Installation
|
||||||
**Android**: Enable "Install from unknown sources" and install the APK
|
**Android**: Enable "Install from unknown sources" and install the APK
|
||||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||||
|
|
||||||
|
  
|
||||||
FOOTER
|
FOOTER
|
||||||
|
|
||||||
echo "Release body:"
|
echo "Release body:"
|
||||||
@@ -397,7 +385,7 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ needs.get-version.outputs.version }}
|
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
|
body_path: /tmp/release_body.txt
|
||||||
files: ./release/*
|
files: ./release/*
|
||||||
draft: false
|
draft: false
|
||||||
@@ -563,7 +551,7 @@ jobs:
|
|||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
-F document=@"${ARM64_APK}" \
|
-F document=@"${ARM64_APK}" \
|
||||||
-F caption="SpotiFLAC Mobile ${VERSION} - arm64 (recommended)"
|
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Upload arm32 APK to channel
|
# Upload arm32 APK to channel
|
||||||
@@ -572,7 +560,7 @@ jobs:
|
|||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
-F document=@"${ARM32_APK}" \
|
-F document=@"${ARM32_APK}" \
|
||||||
-F caption="SpotiFLAC Mobile ${VERSION} - arm32"
|
-F caption="SpotiFLAC ${VERSION} - arm32"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Upload iOS IPA to channel
|
# Upload iOS IPA to channel
|
||||||
@@ -582,7 +570,7 @@ jobs:
|
|||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
-F document=@"${IOS_IPA}" \
|
-F document=@"${IOS_IPA}" \
|
||||||
-F caption="SpotiFLAC Mobile ${VERSION} - iOS (unsigned, sideload required)"
|
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Telegram notification sent!"
|
echo "Telegram notification sent!"
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ go_backend/*.xcframework/
|
|||||||
# Android
|
# Android
|
||||||
android/.gradle/
|
android/.gradle/
|
||||||
android/app/libs/gobackend.aar
|
android/app/libs/gobackend.aar
|
||||||
android/app/libs/gobackend-sources.jar
|
|
||||||
android/local.properties
|
android/local.properties
|
||||||
android/*.iml
|
android/*.iml
|
||||||
android/key.properties
|
android/key.properties
|
||||||
@@ -58,22 +57,17 @@ ios/Pods/
|
|||||||
ios/.symlinks/
|
ios/.symlinks/
|
||||||
ios/Flutter/Flutter.framework/
|
ios/Flutter/Flutter.framework/
|
||||||
ios/Flutter/Flutter.podspec
|
ios/Flutter/Flutter.podspec
|
||||||
|
android/app/libs/gobackend-sources.jar
|
||||||
|
|
||||||
# Extension folder
|
# Extension folder
|
||||||
extension/*
|
extension/
|
||||||
extension/v2/
|
|
||||||
extension/v2/**
|
|
||||||
|
|
||||||
# Agent instructions
|
# Agent instructions
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|
||||||
# Temp/misc
|
# Temp/misc
|
||||||
.tmp/
|
|
||||||
nul
|
nul
|
||||||
NUL
|
|
||||||
network_requests.txt
|
network_requests.txt
|
||||||
*.bak
|
|
||||||
/AndroidManifest.xml
|
|
||||||
|
|
||||||
# Log files
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ Translation files are located in `lib/l10n/arb/`.
|
|||||||
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Use FVM (Flutter Version: 3.41.5)**
|
3. **Use FVM (Flutter Version: 3.38.1)**
|
||||||
```bash
|
```bash
|
||||||
fvm use
|
fvm use
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="assets/readme/banner-readme-dark.png">
|
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
|
||||||
<source media="(prefers-color-scheme: light)" srcset="assets/readme/banner-readme-light.png">
|
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
|
||||||
<img alt="SpotiFLAC Mobile" src="assets/readme/banner-readme-light.png" width="650" height="auto">
|
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://trendshift.io/repositories/25971" target="_blank">
|
<a href="https://trendshift.io/repositories/17247">
|
||||||
<img src="https://trendshift.io/api/badge/repositories/25971" alt="spotiflacapp%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
<img src="https://trendshift.io/api/badge/repositories/17247" alt="zarzet%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -28,10 +28,10 @@
|
|||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="assets/readme/1.jpg?v=2" width="200" />
|
<img src="assets/images/1.jpg?v=2" width="200" />
|
||||||
<img src="assets/readme/2.jpg?v=2" width="200" />
|
<img src="assets/images/2.jpg?v=2" width="200" />
|
||||||
<img src="assets/readme/3.jpg?v=2" width="200" />
|
<img src="assets/images/3.jpg?v=2" width="200" />
|
||||||
<img src="assets/readme/4.jpg?v=2" width="200" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -59,7 +59,7 @@ Extensions let the community add new music sources and features without waiting
|
|||||||
## Related Projects
|
## Related Projects
|
||||||
|
|
||||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Download music in true lossless FLAC from extension-provided sources on Windows, macOS & Linux.
|
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for Windows, macOS & Linux.
|
||||||
|
|
||||||
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||||
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
|
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
|
||||||
@@ -80,7 +80,7 @@ Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository
|
|||||||
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
The track may not be available from your enabled providers. Try enabling more providers under **Settings > Extensions > Provider Priority**, or install additional download extensions from the Store.
|
The track may not be available on the streaming services. Try enabling more providers under **Settings > Download > Provider Priority**, or install additional extensions like Amazon Music from the Store.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -88,7 +88,10 @@ The track may not be available from your enabled providers. Try enabling more pr
|
|||||||
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
Quality depends on what's available from the source and the installed download extension. Check each extension's quality options and service notes in the app.
|
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
|
||||||
|
- **Tidal** up to 24-bit/192kHz
|
||||||
|
- **Qobuz** up to 24-bit/192kHz
|
||||||
|
- **Deezer** up to 16-bit/44.1kHz
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -163,8 +166,9 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md)
|
|||||||
|
|
||||||
| | | | | |
|
| | | | | |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| [MusicDL](https://www.musicdl.me) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) | [Song.link](https://song.link) |
|
| [hifi-api](https://github.com/binimum/hifi-api) | [music.binimum.org](https://music.binimum.org) | [qqdl.site](https://qqdl.site) | [squid.wtf](https://squid.wtf) | [spotisaver.net](https://spotisaver.net) |
|
||||||
| [IDHS](https://github.com/sjdonado/idonthavespotify) | | | | |
|
| [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) |
|
||||||
|
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,6 @@
|
|||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
plugins:
|
|
||||||
riverpod_lint: 3.1.4-dev.3
|
|
||||||
|
|
||||||
analyzer:
|
analyzer:
|
||||||
exclude:
|
exclude:
|
||||||
- build/**
|
- build/**
|
||||||
@@ -22,6 +19,9 @@ analyzer:
|
|||||||
strict-casts: true
|
strict-casts: true
|
||||||
strict-inference: true
|
strict-inference: true
|
||||||
strict-raw-types: true
|
strict-raw-types: true
|
||||||
|
plugins:
|
||||||
|
- custom_lint
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
@@ -36,13 +36,13 @@ linter:
|
|||||||
rules:
|
rules:
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
always_declare_return_types: true
|
|
||||||
avoid_dynamic_calls: true
|
avoid_dynamic_calls: true
|
||||||
avoid_types_as_parameter_names: true
|
|
||||||
strict_top_level_inference: true
|
|
||||||
type_annotate_public_apis: true
|
|
||||||
cancel_subscriptions: true
|
cancel_subscriptions: true
|
||||||
close_sinks: true
|
close_sinks: true
|
||||||
|
|
||||||
|
custom_lint:
|
||||||
|
rules:
|
||||||
|
- avoid_public_notifier_properties
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
plugins {
|
||||||
|
id "com.android.application"
|
||||||
|
id "kotlin-android"
|
||||||
|
id "dev.flutter.flutter-gradle-plugin"
|
||||||
|
}
|
||||||
|
|
||||||
|
def localProperties = new Properties()
|
||||||
|
def localPropertiesFile = rootProject.file('local.properties')
|
||||||
|
if (localPropertiesFile.exists()) {
|
||||||
|
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||||
|
localProperties.load(reader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||||
|
if (flutterVersionCode == null) {
|
||||||
|
flutterVersionCode = '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||||
|
if (flutterVersionName == null) {
|
||||||
|
flutterVersionName = '1.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "com.zarz.spotiflac"
|
||||||
|
compileSdk flutter.compileSdkVersion
|
||||||
|
ndkVersion flutter.ndkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '1.8'
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
main.java.srcDirs += 'src/main/kotlin'
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.zarz.spotiflac"
|
||||||
|
minSdkVersion flutter.minSdkVersion
|
||||||
|
targetSdk flutter.targetSdkVersion
|
||||||
|
versionCode flutterVersionCode.toInteger()
|
||||||
|
versionName flutterVersionName
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
|
minifyEnabled false
|
||||||
|
shrinkResources false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source '../..'
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Go backend library (gomobile generated)
|
||||||
|
implementation fileTree(dir: 'libs', include: ['*.aar'])
|
||||||
|
|
||||||
|
// Kotlin coroutines for async Go backend calls
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
|
||||||
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||||
|
}
|
||||||
@@ -17,22 +17,18 @@ if (keystorePropertiesFile.exists()) {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.zarz.spotiflac"
|
namespace = "com.zarz.spotiflac"
|
||||||
compileSdk = 37
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
buildConfig = true
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_25
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_25
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +46,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.zarz.spotiflac"
|
applicationId = "com.zarz.spotiflac"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = 37
|
targetSdk = 36
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
multiDexEnabled = true
|
multiDexEnabled = true
|
||||||
@@ -61,20 +57,6 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
getByName("debug") {
|
|
||||||
applicationIdSuffix = ".debug"
|
|
||||||
versionNameSuffix = "-debug"
|
|
||||||
ndk {
|
|
||||||
debugSymbolLevel = "FULL"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getByName("profile") {
|
|
||||||
ndk {
|
|
||||||
debugSymbolLevel = "FULL"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
release {
|
release {
|
||||||
// For local builds: use release signing if key.properties exists
|
// For local builds: use release signing if key.properties exists
|
||||||
// For CI builds: APK is signed by GitHub Action after build
|
// For CI builds: APK is signed by GitHub Action after build
|
||||||
@@ -89,9 +71,6 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
)
|
)
|
||||||
ndk {
|
|
||||||
debugSymbolLevel = "FULL"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,9 +101,8 @@ dependencies {
|
|||||||
// Include all AAR and JAR files from libs folder
|
// Include all AAR and JAR files from libs folder
|
||||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0-beta02")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||||
implementation("androidx.activity:activity-ktx:1.13.0")
|
implementation("androidx.activity:activity-ktx:1.12.3")
|
||||||
implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="SpotiFLAC Mobile"
|
android:label="SpotiFLAC"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:usesCleartextTraffic="false"
|
android:usesCleartextTraffic="false"
|
||||||
@@ -86,26 +86,6 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="https" android:host="music.youtube.com" />
|
<data android:scheme="https" android:host="music.youtube.com" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Extension OAuth (PKCE) redirect: spotiflac://callback?code=...&state=<extension_id> -->
|
|
||||||
<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="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="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>
|
</activity>
|
||||||
|
|
||||||
<!-- Download Service -->
|
<!-- Download Service -->
|
||||||
@@ -114,15 +94,16 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<!-- Audio playback service for media notification / background audio -->
|
||||||
<service
|
<service
|
||||||
android:name="com.ryanheise.audioservice.AudioService"
|
android:name="com.ryanheise.audioservice.AudioService"
|
||||||
android:foregroundServiceType="mediaPlayback"
|
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:enabled="true">
|
android:foregroundServiceType="mediaPlayback">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.media.browse.MediaBrowserService" />
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
@@ -147,10 +128,6 @@
|
|||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
<meta-data
|
|
||||||
android:name="com.google.android.gms.car.application"
|
|
||||||
android:resource="@xml/automotive_app_desc" />
|
|
||||||
|
|
||||||
<!-- FileProvider for APK installation -->
|
<!-- FileProvider for APK installation -->
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.example.temp_project
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
@@ -1,496 +0,0 @@
|
|||||||
package com.zarz.spotiflac
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.File
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared SAF download wrapper for foreground activity calls and service-owned
|
|
||||||
* native workers.
|
|
||||||
*/
|
|
||||||
object SafDownloadHandler {
|
|
||||||
private val safDirLock = Any()
|
|
||||||
private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
|
|
||||||
private const val STAGED_SAF_MIME_TYPE = "application/octet-stream"
|
|
||||||
|
|
||||||
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
|
|
||||||
val req = JSONObject(requestJson)
|
|
||||||
val storageMode = req.optString("storage_mode", "")
|
|
||||||
val treeUriStr = req.optString("saf_tree_uri", "")
|
|
||||||
if (storageMode != "saf" || treeUriStr.isBlank()) {
|
|
||||||
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 mimeType = mimeTypeForExt(outputExt)
|
|
||||||
val fileName = buildSafFileName(req, outputExt)
|
|
||||||
val deferSafPublish = req.optBoolean("defer_saf_publish", false)
|
|
||||||
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
|
|
||||||
|
|
||||||
val existingDir = findDocumentDir(context, treeUri, relativeDir)
|
|
||||||
if (existingDir != null) {
|
|
||||||
val existing = existingDir.findFile(fileName)
|
|
||||||
if (existing != null && existing.isFile && existing.length() > 0) {
|
|
||||||
if (useStagedOutput || deferSafPublish) {
|
|
||||||
deleteStaleStagedFiles(existingDir, fileName, outputExt)
|
|
||||||
}
|
|
||||||
val obj = JSONObject()
|
|
||||||
obj.put("success", true)
|
|
||||||
obj.put("message", "File already exists")
|
|
||||||
obj.put("file_path", existing.uri.toString())
|
|
||||||
obj.put("file_name", existing.name ?: fileName)
|
|
||||||
obj.put("already_exists", true)
|
|
||||||
return obj.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val targetDir = ensureDocumentDir(context, treeUri, relativeDir)
|
|
||||||
?: return errorJson("Failed to access SAF directory")
|
|
||||||
|
|
||||||
if (deferSafPublish) {
|
|
||||||
deleteStaleStagedFiles(targetDir, fileName, outputExt)
|
|
||||||
val workingExt = outputExt.ifBlank { ".tmp" }
|
|
||||||
val workingFile = File.createTempFile("native_saf_work_", workingExt, context.cacheDir)
|
|
||||||
Log.i("SpotiFLAC", "SAF deferred native output: target=$fileName working=${workingFile.name}")
|
|
||||||
return try {
|
|
||||||
req.put("output_path", workingFile.absolutePath)
|
|
||||||
req.put("output_ext", outputExt)
|
|
||||||
req.remove("output_fd")
|
|
||||||
val response = downloader(req.toString())
|
|
||||||
val respObj = JSONObject(response)
|
|
||||||
if (respObj.optBoolean("success", false)) {
|
|
||||||
val reportedPath = respObj.optString("file_path", "").trim()
|
|
||||||
if (reportedPath.isEmpty() || reportedPath.startsWith("/proc/self/fd/")) {
|
|
||||||
respObj.put("file_path", workingFile.absolutePath)
|
|
||||||
} else if (reportedPath != workingFile.absolutePath) {
|
|
||||||
workingFile.delete()
|
|
||||||
}
|
|
||||||
respObj.put("file_name", respObj.optString("file_name", "").ifBlank { fileName })
|
|
||||||
respObj.put("saf_deferred_publish", true)
|
|
||||||
respObj.put("saf_final_file_name", fileName)
|
|
||||||
respObj.put("saf_relative_dir", relativeDir)
|
|
||||||
respObj.put("saf_tree_uri", treeUriStr)
|
|
||||||
respObj.put("saf_output_ext", outputExt)
|
|
||||||
respObj.put("saf_final_mime_type", mimeType)
|
|
||||||
} else {
|
|
||||||
workingFile.delete()
|
|
||||||
}
|
|
||||||
respObj.toString()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
workingFile.delete()
|
|
||||||
errorJson("SAF deferred download failed: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
|
|
||||||
?: return errorJson("Failed to create SAF file")
|
|
||||||
|
|
||||||
val pfd = context.contentResolver.openFileDescriptor(document.uri, "rw")
|
|
||||||
?: return errorJson("Failed to open SAF file")
|
|
||||||
|
|
||||||
var detachedFd: Int? = null
|
|
||||||
try {
|
|
||||||
detachedFd = pfd.detachFd()
|
|
||||||
req.put("output_path", "")
|
|
||||||
req.put("output_fd", detachedFd)
|
|
||||||
req.put("output_ext", outputExt)
|
|
||||||
val response = downloader(req.toString())
|
|
||||||
val respObj = JSONObject(response)
|
|
||||||
if (respObj.optBoolean("success", false)) {
|
|
||||||
val goFilePath = respObj.optString("file_path", "")
|
|
||||||
if (goFilePath.isNotEmpty() &&
|
|
||||||
!goFilePath.startsWith("content://") &&
|
|
||||||
!goFilePath.startsWith("/proc/self/fd/")
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
val srcFile = File(goFilePath)
|
|
||||||
if (!srcFile.exists() || srcFile.length() <= 0) {
|
|
||||||
throw IllegalStateException("extension output missing or empty: $goFilePath")
|
|
||||||
}
|
|
||||||
val actualExt = normalizeExt(srcFile.extension)
|
|
||||||
if (actualExt.isNotBlank()) {
|
|
||||||
respObj.put("actual_extension", actualExt)
|
|
||||||
}
|
|
||||||
if (actualExt.isNotBlank() && actualExt != outputExt) {
|
|
||||||
val actualFileName = buildSafFileName(req, actualExt)
|
|
||||||
val actualStagedFileName = if (useStagedOutput) {
|
|
||||||
buildStagedSafFileName(actualFileName)
|
|
||||||
} else {
|
|
||||||
actualFileName
|
|
||||||
}
|
|
||||||
val actualMimeType = mimeTypeForExt(actualExt)
|
|
||||||
val replacement = createOrReuseDocumentFile(
|
|
||||||
targetDir,
|
|
||||||
if (useStagedOutput) STAGED_SAF_MIME_TYPE else actualMimeType,
|
|
||||||
actualStagedFileName
|
|
||||||
) ?: throw IllegalStateException(
|
|
||||||
"failed to create SAF output with actual extension"
|
|
||||||
)
|
|
||||||
if (replacement.uri != document.uri) {
|
|
||||||
document.delete()
|
|
||||||
document = replacement
|
|
||||||
}
|
|
||||||
}
|
|
||||||
context.contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
|
|
||||||
srcFile.inputStream().use { input ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
} ?: throw IllegalStateException("failed to open SAF output stream")
|
|
||||||
srcFile.delete()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
document.delete()
|
|
||||||
android.util.Log.w(
|
|
||||||
"SpotiFLAC",
|
|
||||||
"Failed to copy extension output to SAF: ${e.message}"
|
|
||||||
)
|
|
||||||
return errorJson("Failed to copy extension output to SAF: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
respObj.put("file_path", document.uri.toString())
|
|
||||||
respObj.put("file_name", document.name ?: fileName)
|
|
||||||
if (useStagedOutput) {
|
|
||||||
respObj.put("saf_staged_output", true)
|
|
||||||
respObj.put("saf_staged_file_name", document.name ?: stagedFileName)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
document.delete()
|
|
||||||
}
|
|
||||||
return respObj.toString()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
document.delete()
|
|
||||||
return errorJson("SAF download failed: ${e.message}")
|
|
||||||
} finally {
|
|
||||||
if (detachedFd == null) {
|
|
||||||
try {
|
|
||||||
pfd.close()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun copyContentUriToTemp(context: Context, uriStr: String): String? {
|
|
||||||
return try {
|
|
||||||
val uri = Uri.parse(uriStr)
|
|
||||||
val extension = DocumentFile.fromSingleUri(context, uri)
|
|
||||||
?.name
|
|
||||||
?.substringAfterLast('.', "")
|
|
||||||
?.takeIf { it.isNotBlank() }
|
|
||||||
?.let { ".$it" }
|
|
||||||
?: ".tmp"
|
|
||||||
val temp = File.createTempFile("native_saf_", extension, context.cacheDir)
|
|
||||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
|
||||||
temp.outputStream().use { output ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
} ?: return null
|
|
||||||
temp.absolutePath
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.w("SpotiFLAC", "Failed to copy SAF URI to temp: ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeFileToSaf(
|
|
||||||
context: Context,
|
|
||||||
treeUriStr: String,
|
|
||||||
relativeDir: String,
|
|
||||||
fileName: String,
|
|
||||||
mimeType: 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)
|
|
||||||
val document = createOrReuseDocumentFile(targetDir, STAGED_SAF_MIME_TYPE, stagedName)
|
|
||||||
?: return null
|
|
||||||
stagedDocument = document
|
|
||||||
val outputStream = context.contentResolver.openOutputStream(document.uri, "wt")
|
|
||||||
if (outputStream == null) {
|
|
||||||
document.delete()
|
|
||||||
stagedDocument = null
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
outputStream.use { output ->
|
|
||||||
File(srcPath).inputStream().use { input ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val existingFinal = targetDir.findFile(finalName)
|
|
||||||
if (existingFinal != null && existingFinal.uri != document.uri) {
|
|
||||||
existingFinal.delete()
|
|
||||||
}
|
|
||||||
if (!document.renameTo(finalName)) {
|
|
||||||
document.delete()
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
stagedDocument = null
|
|
||||||
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}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteContentUri(context: Context, uriStr: String): Boolean {
|
|
||||||
return try {
|
|
||||||
DocumentFile.fromSingleUri(context, Uri.parse(uriStr))?.delete() == true
|
|
||||||
} catch (_: Exception) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeExt(ext: String?): String {
|
|
||||||
if (ext.isNullOrBlank()) return ""
|
|
||||||
return if (ext.startsWith(".")) {
|
|
||||||
ext.lowercase(Locale.ROOT)
|
|
||||||
} else {
|
|
||||||
".${ext.lowercase(Locale.ROOT)}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mimeTypeForExt(ext: String?): String {
|
|
||||||
return when (normalizeExt(ext)) {
|
|
||||||
".m4a", ".mp4" -> "audio/mp4"
|
|
||||||
".mp3" -> "audio/mpeg"
|
|
||||||
".opus" -> "audio/ogg"
|
|
||||||
".flac" -> "audio/flac"
|
|
||||||
".lrc" -> "application/octet-stream"
|
|
||||||
else -> "application/octet-stream"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun forceFilenameExt(name: String, outputExt: String): String {
|
|
||||||
val normalizedExt = normalizeExt(outputExt)
|
|
||||||
if (normalizedExt.isBlank()) return sanitizeFilename(name)
|
|
||||||
|
|
||||||
val safeName = sanitizeFilename(name)
|
|
||||||
val lower = safeName.lowercase(Locale.ROOT)
|
|
||||||
val knownExts = listOf(".flac", ".m4a", ".mp4", ".mp3", ".opus", ".lrc")
|
|
||||||
for (knownExt in knownExts) {
|
|
||||||
if (lower.endsWith(knownExt)) {
|
|
||||||
return safeName.dropLast(knownExt.length) + normalizedExt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return safeName + normalizedExt
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildStagedSafFileName(fileName: String): String {
|
|
||||||
val safeName = sanitizeFilename(fileName)
|
|
||||||
return "$safeName.partial"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildLegacyStagedSafFileName(fileName: String, outputExt: String): String {
|
|
||||||
val safeName = sanitizeFilename(fileName)
|
|
||||||
val ext = normalizeExt(outputExt)
|
|
||||||
if (ext.isNotBlank() && safeName.lowercase(Locale.ROOT).endsWith(ext)) {
|
|
||||||
return safeName.dropLast(ext.length).trimEnd('.', ' ') + ".partial$ext"
|
|
||||||
}
|
|
||||||
val dot = safeName.lastIndexOf('.')
|
|
||||||
if (dot > 0 && dot < safeName.lastIndex) {
|
|
||||||
return safeName.substring(0, dot).trimEnd('.', ' ') +
|
|
||||||
".partial" +
|
|
||||||
safeName.substring(dot)
|
|
||||||
}
|
|
||||||
return "$safeName.partial"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deleteStaleStagedFiles(parent: DocumentFile, fileName: String, outputExt: String) {
|
|
||||||
val stagedNames = linkedSetOf(
|
|
||||||
buildStagedSafFileName(fileName),
|
|
||||||
buildLegacyStagedSafFileName(fileName, outputExt)
|
|
||||||
)
|
|
||||||
for (stagedName in stagedNames) {
|
|
||||||
try {
|
|
||||||
parent.findFile(stagedName)?.delete()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sanitizeFilename(name: String): String {
|
|
||||||
var sanitized = name
|
|
||||||
.replace("/", " ")
|
|
||||||
.replace(Regex("[\\\\:*?\"<>|]"), " ")
|
|
||||||
.filter { ch ->
|
|
||||||
val code = ch.code
|
|
||||||
!((code < 0x20 && ch != '\t' && ch != '\n' && ch != '\r') ||
|
|
||||||
code == 0x7F ||
|
|
||||||
(Character.isISOControl(ch) && ch != '\t' && ch != '\n' && ch != '\r'))
|
|
||||||
}
|
|
||||||
.trim()
|
|
||||||
.trim('.', ' ')
|
|
||||||
|
|
||||||
sanitized = sanitized
|
|
||||||
.replace(Regex("\\s+"), " ")
|
|
||||||
.replace(Regex("_+"), "_")
|
|
||||||
.trim('_', ' ')
|
|
||||||
|
|
||||||
sanitized = truncateSafDisplayName(sanitized, MAX_SAF_DISPLAY_NAME_UTF8_BYTES)
|
|
||||||
sanitized = sanitized.trim().trim('.', ' ').trim('_', ' ')
|
|
||||||
return if (sanitized.isBlank()) "Unknown" else sanitized
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun truncateSafDisplayName(name: String, maxBytes: Int): String {
|
|
||||||
if (maxBytes <= 0 || name.toByteArray(Charsets.UTF_8).size <= maxBytes) return name
|
|
||||||
|
|
||||||
val dotIndex = name.lastIndexOf('.')
|
|
||||||
val ext = if (
|
|
||||||
dotIndex > 0 &&
|
|
||||||
dotIndex < name.length - 1 &&
|
|
||||||
name.length - dotIndex <= 10
|
|
||||||
) {
|
|
||||||
name.substring(dotIndex)
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
val stem = if (ext.isNotEmpty()) name.substring(0, dotIndex) else name
|
|
||||||
val maxStemBytes = (maxBytes - ext.toByteArray(Charsets.UTF_8).size).coerceAtLeast(1)
|
|
||||||
return truncateUtf8Bytes(stem, maxStemBytes).trim().trim('.', ' ').trim('_', ' ') + ext
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun truncateUtf8Bytes(value: String, maxBytes: Int): String {
|
|
||||||
if (maxBytes <= 0 || value.toByteArray(Charsets.UTF_8).size <= maxBytes) return value
|
|
||||||
|
|
||||||
val builder = StringBuilder()
|
|
||||||
var usedBytes = 0
|
|
||||||
var index = 0
|
|
||||||
while (index < value.length) {
|
|
||||||
val codePoint = value.codePointAt(index)
|
|
||||||
val char = String(Character.toChars(codePoint))
|
|
||||||
val charBytes = char.toByteArray(Charsets.UTF_8).size
|
|
||||||
if (usedBytes + charBytes > maxBytes) break
|
|
||||||
builder.append(char)
|
|
||||||
usedBytes += charBytes
|
|
||||||
index += Character.charCount(codePoint)
|
|
||||||
}
|
|
||||||
return builder.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sanitizeRelativeDir(relativeDir: String): String {
|
|
||||||
if (relativeDir.isBlank()) return ""
|
|
||||||
return relativeDir
|
|
||||||
.split("/")
|
|
||||||
.map { sanitizeFilename(it) }
|
|
||||||
.filter { it.isNotBlank() && it != "." && it != ".." }
|
|
||||||
.joinToString("/")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureDocumentDir(
|
|
||||||
context: Context,
|
|
||||||
treeUri: Uri,
|
|
||||||
relativeDir: String
|
|
||||||
): DocumentFile? {
|
|
||||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
|
||||||
if (safeRelativeDir.isBlank()) {
|
|
||||||
return DocumentFile.fromTreeUri(context, treeUri)
|
|
||||||
}
|
|
||||||
|
|
||||||
synchronized(safDirLock) {
|
|
||||||
var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null
|
|
||||||
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
|
||||||
for (part in parts) {
|
|
||||||
val existing = current.findFile(part)
|
|
||||||
current = if (existing != null && existing.isDirectory) {
|
|
||||||
existing
|
|
||||||
} else {
|
|
||||||
val created = current.createDirectory(part) ?: return null
|
|
||||||
val createdName = created.name ?: part
|
|
||||||
if (createdName != part) {
|
|
||||||
created.delete()
|
|
||||||
current.findFile(part) ?: return null
|
|
||||||
} else {
|
|
||||||
created
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findDocumentDir(
|
|
||||||
context: Context,
|
|
||||||
treeUri: Uri,
|
|
||||||
relativeDir: String
|
|
||||||
): DocumentFile? {
|
|
||||||
var current = DocumentFile.fromTreeUri(context, treeUri) ?: return null
|
|
||||||
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
|
|
||||||
if (safeRelativeDir.isBlank()) return current
|
|
||||||
|
|
||||||
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
|
|
||||||
for (part in parts) {
|
|
||||||
val existing = current.findFile(part)
|
|
||||||
if (existing == null || !existing.isDirectory) return null
|
|
||||||
current = existing
|
|
||||||
}
|
|
||||||
return current
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createOrReuseDocumentFile(
|
|
||||||
parent: DocumentFile,
|
|
||||||
mimeType: String,
|
|
||||||
fileName: String
|
|
||||||
): DocumentFile? {
|
|
||||||
val safeFileName = sanitizeFilename(fileName)
|
|
||||||
if (safeFileName.isBlank()) return null
|
|
||||||
|
|
||||||
synchronized(safDirLock) {
|
|
||||||
val existing = parent.findFile(safeFileName)
|
|
||||||
if (existing != null && existing.isFile) {
|
|
||||||
return existing
|
|
||||||
}
|
|
||||||
|
|
||||||
val created = parent.createFile(mimeType, safeFileName) ?: return null
|
|
||||||
val createdName = created.name ?: safeFileName
|
|
||||||
if (createdName == safeFileName) {
|
|
||||||
return created
|
|
||||||
}
|
|
||||||
|
|
||||||
val winner = parent.findFile(safeFileName)
|
|
||||||
if (winner != null && winner.isFile) {
|
|
||||||
if (winner.uri != created.uri) {
|
|
||||||
try {
|
|
||||||
created.delete()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return winner
|
|
||||||
}
|
|
||||||
|
|
||||||
return created
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
|
|
||||||
val provided = req.optString("saf_file_name", "")
|
|
||||||
if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt)
|
|
||||||
|
|
||||||
val trackName = req.optString("track_name", "track")
|
|
||||||
val artistName = req.optString("artist_name", "")
|
|
||||||
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
|
|
||||||
return forceFilenameExt(baseName, outputExt)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun errorJson(message: String): String {
|
|
||||||
val obj = JSONObject()
|
|
||||||
obj.put("success", false)
|
|
||||||
obj.put("error", message)
|
|
||||||
obj.put("message", message)
|
|
||||||
return obj.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,4 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?android:colorBackground" />
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,4 +1,12 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/white" />
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
@@ -6,9 +6,4 @@
|
|||||||
android:drawable="@drawable/ic_launcher_foreground"
|
android:drawable="@drawable/ic_launcher_foreground"
|
||||||
android:inset="16%" />
|
android:inset="16%" />
|
||||||
</foreground>
|
</foreground>
|
||||||
<monochrome>
|
|
||||||
<inset
|
|
||||||
android:drawable="@drawable/ic_launcher_monochrome"
|
|
||||||
android:inset="16%" />
|
|
||||||
</monochrome>
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 954 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 647 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -1,8 +1,17 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
</style>
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
</style>
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<automotiveApp>
|
|
||||||
<uses name="media" />
|
|
||||||
</automotiveApp>
|
|
||||||
@@ -11,8 +11,8 @@ subprojects {
|
|||||||
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
||||||
compileOptions {
|
compileOptions {
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_25
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_25
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable multidex for all subprojects
|
// Enable multidex for all subprojects
|
||||||
@@ -27,7 +27,7 @@ subprojects {
|
|||||||
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,2 @@
|
|||||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
|
||||||
android.builtInKotlin=false
|
|
||||||
# This newDsl flag was added automatically by Flutter migrator
|
|
||||||
android.newDsl=false
|
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-all.zip
|
|
||||||
networkTimeout=10000
|
|
||||||
retries=0
|
|
||||||
retryBackOffMs=500
|
|
||||||
validateDistributionUrl=true
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "9.2.1" apply false
|
id("com.android.application") version "8.13.2" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
|
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "SpotiFLAC Mobile Source",
|
"name": "SpotiFLAC Source",
|
||||||
"identifier": "com.zarzet.spotiflac.source",
|
"identifier": "com.zarzet.spotiflac.source",
|
||||||
"subtitle": "FLAC Downloader for iOS",
|
"subtitle": "FLAC Downloader for iOS",
|
||||||
"apps": [
|
"apps": [
|
||||||
{
|
{
|
||||||
"name": "SpotiFLAC Mobile",
|
"name": "SpotiFLAC",
|
||||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||||
"developerName": "zarzet",
|
"developerName": "zarzet",
|
||||||
"version": "4.7.1",
|
"version": "3.9.0",
|
||||||
"versionDate": "2026-07-01",
|
"versionDate": "2026-03-25",
|
||||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.7.1/SpotiFLAC-v4.7.1-ios-unsigned.ipa",
|
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.9.0/SpotiFLAC-v3.9.0-ios-unsigned.ipa",
|
||||||
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
"localizedDescription": "Mobile version of SpotiFLAC 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",
|
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||||
"size": 37455821
|
"size": 34477323
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 539 KiB After Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 811 KiB After Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 70 KiB |
@@ -3,21 +3,17 @@ files:
|
|||||||
translation: /lib/l10n/arb/app_%locale%.arb
|
translation: /lib/l10n/arb/app_%locale%.arb
|
||||||
languages_mapping:
|
languages_mapping:
|
||||||
locale:
|
locale:
|
||||||
# Keys MUST be the project's Crowdin language ids; values are the
|
# Short codes for single-variant languages
|
||||||
# %locale% suffix used in app_%locale%.arb (underscores so Flutter
|
|
||||||
# gen-l10n parses them — hyphenated filenames break gen-l10n).
|
|
||||||
ar: ar
|
|
||||||
de: de
|
de: de
|
||||||
es-ES: es_ES
|
es: es
|
||||||
fr: fr
|
fr: fr
|
||||||
hi: hi
|
hi: hi
|
||||||
id: id
|
id: id
|
||||||
ja: ja
|
ja: ja
|
||||||
ko: ko
|
ko: ko
|
||||||
nl: nl
|
nl: nl
|
||||||
pt-PT: pt_PT
|
pt: pt
|
||||||
ru: ru
|
ru: ru
|
||||||
tr: tr
|
# Full codes for Chinese variants
|
||||||
uk: uk
|
|
||||||
zh-CN: zh_CN
|
zh-CN: zh_CN
|
||||||
zh-TW: zh_TW
|
zh-TW: zh_TW
|
||||||
|
|||||||
@@ -1,322 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/binary"
|
|
||||||
"fmt"
|
|
||||||
"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)
|
|
||||||
if size == 1 {
|
|
||||||
if pos+16 > n {
|
|
||||||
return mp4Box{}, false
|
|
||||||
}
|
|
||||||
size = int64(binary.BigEndian.Uint64(data[pos+8 : pos+16]))
|
|
||||||
hdr = 16
|
|
||||||
} else if size == 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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
if ftyp, ok := findChildMP4(data, 0, int64(len(data)), "ftyp"); ok {
|
|
||||||
if ftyp.body()+4 <= int64(len(data)) {
|
|
||||||
copy(data[ftyp.body():ftyp.body()+4], []byte("mp42"))
|
|
||||||
}
|
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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], 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error {
|
|
||||||
dst, err := os.ReadFile(decryptedPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := locateAC4Entry(dst); !ok {
|
|
||||||
return nil // not an AC-4 file; nothing to do
|
|
||||||
}
|
|
||||||
|
|
||||||
dst = normalizeQuickTimeAudioToMP4(dst)
|
|
||||||
|
|
||||||
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 os.WriteFile(decryptedPath, dst, 0o644)
|
|
||||||
}
|
|
||||||
|
|
||||||
src, err := os.ReadFile(sourcePath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
srcMoov, ok := findChildMP4(src, 0, int64(len(src)), "moov")
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("source has no moov")
|
|
||||||
}
|
|
||||||
dac4Box, ok := findBoxBySignature(src, srcMoov.body(), srcMoov.end(), "dac4")
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("dac4 not found in source")
|
|
||||||
}
|
|
||||||
dac4 := append([]byte{}, src[dac4Box.offset:dac4Box.end()]...)
|
|
||||||
|
|
||||||
insertPos := childStart
|
|
||||||
delta := int64(len(dac4))
|
|
||||||
|
|
||||||
shiftChunkOffsets(dst, loc.chain[0], 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 os.WriteFile(decryptedPath, out, 0o644)
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/binary"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func mp4TestBox(typ string, body []byte) []byte {
|
|
||||||
out := make([]byte, 8+len(body))
|
|
||||||
binary.BigEndian.PutUint32(out[:4], uint32(len(out)))
|
|
||||||
copy(out[4:8], typ)
|
|
||||||
copy(out[8:], body)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func mp4TestAC4Tree(entryBody []byte) []byte {
|
|
||||||
entry := mp4TestBox("ac-4", entryBody)
|
|
||||||
stsdBody := append([]byte{
|
|
||||||
0, 0, 0, 0, // version/flags
|
|
||||||
0, 0, 0, 1, // entry_count
|
|
||||||
}, entry...)
|
|
||||||
stsd := mp4TestBox("stsd", stsdBody)
|
|
||||||
stbl := mp4TestBox("stbl", stsd)
|
|
||||||
minf := mp4TestBox("minf", stbl)
|
|
||||||
mdia := mp4TestBox("mdia", minf)
|
|
||||||
trak := mp4TestBox("trak", mdia)
|
|
||||||
moov := mp4TestBox("moov", trak)
|
|
||||||
return moov
|
|
||||||
}
|
|
||||||
|
|
||||||
func shortAC4SampleEntryBody(version uint16) []byte {
|
|
||||||
body := make([]byte, 10)
|
|
||||||
binary.BigEndian.PutUint16(body[8:10], version)
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeQuickTimeAudioToMP4IgnoresTruncatedAC4Entry(t *testing.T) {
|
|
||||||
input := mp4TestAC4Tree(shortAC4SampleEntryBody(1))
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
t.Fatalf("normalizeQuickTimeAudioToMP4 panicked: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
got := normalizeQuickTimeAudioToMP4(append([]byte{}, input...))
|
|
||||||
if !bytes.Equal(got, input) {
|
|
||||||
t.Fatal("truncated QuickTime AC-4 entry should be left unchanged")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureAC4ConfigBoxRejectsTruncatedAC4Entry(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
decryptedPath := filepath.Join(dir, "decrypted.mp4")
|
|
||||||
sourcePath := filepath.Join(dir, "source.mp4")
|
|
||||||
|
|
||||||
if err := os.WriteFile(decryptedPath, mp4TestAC4Tree(shortAC4SampleEntryBody(2)), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(sourcePath, mp4TestBox("moov", mp4TestBox("dac4", []byte{1, 2, 3, 4})), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
t.Fatalf("EnsureAC4ConfigBox panicked: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if err := EnsureAC4ConfigBox(decryptedPath, sourcePath); err == nil {
|
|
||||||
t.Fatal("expected malformed AC-4 sample entry error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,182 +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.
|
|
||||||
func writeMP4iTunesMetadata(data []byte, 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, 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, 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).
|
|
||||||
func WriteAC4MetadataIfApplicable(decryptedPath, metadataJSON, coverPath string) (bool, error) {
|
|
||||||
data, err := os.ReadFile(decryptedPath)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if _, ok := locateAC4Entry(data); !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(data, md, cover)
|
|
||||||
if err := os.WriteFile(decryptedPath, out, 0o644); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ func ReadAPETags(filePath string) (*APETag, error) {
|
|||||||
return nil, fmt.Errorf("file too small for APE tag")
|
return nil, fmt.Errorf("file too small for APE tag")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to find APE tag footer at the end of file.
|
||||||
// The footer is the last 32 bytes before any ID3v1 tag (128 bytes).
|
// The footer is the last 32 bytes before any ID3v1 tag (128 bytes).
|
||||||
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
|
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -254,6 +256,7 @@ func findExistingAPETagSize(filePath string) (int64, error) {
|
|||||||
|
|
||||||
tagSize := int64(binary.LittleEndian.Uint32(footer[12:16]))
|
tagSize := int64(binary.LittleEndian.Uint32(footer[12:16]))
|
||||||
|
|
||||||
|
// Check if there's also a header (tagSize only covers items + footer)
|
||||||
hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header
|
hasHeader := (flags & (1 << 31)) != 0 // bit 31 = tag contains header
|
||||||
totalSize := tagSize
|
totalSize := tagSize
|
||||||
if hasHeader {
|
if hasHeader {
|
||||||
@@ -314,6 +317,7 @@ func marshalAPETag(tag *APETag) ([]byte, error) {
|
|||||||
footerFlags := uint32(1 << 31)
|
footerFlags := uint32(1 << 31)
|
||||||
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
|
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
|
||||||
|
|
||||||
|
// Final layout: header + items + footer
|
||||||
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
|
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
|
||||||
result = append(result, header...)
|
result = append(result, header...)
|
||||||
result = append(result, itemsData...)
|
result = append(result, itemsData...)
|
||||||
@@ -363,9 +367,12 @@ func APETagToAudioMetadata(tag *APETag) *AudioMetadata {
|
|||||||
case "DATE":
|
case "DATE":
|
||||||
metadata.Date = value
|
metadata.Date = value
|
||||||
case "TRACK", "TRACKNUMBER":
|
case "TRACK", "TRACKNUMBER":
|
||||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
// APE track format can be "3" or "3/12"
|
||||||
|
trackNum, _ := strconv.Atoi(strings.Split(value, "/")[0])
|
||||||
|
metadata.TrackNumber = trackNum
|
||||||
case "DISC", "DISCNUMBER":
|
case "DISC", "DISCNUMBER":
|
||||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
discNum, _ := strconv.Atoi(strings.Split(value, "/")[0])
|
||||||
|
metadata.DiscNumber = discNum
|
||||||
case "ISRC":
|
case "ISRC":
|
||||||
metadata.ISRC = value
|
metadata.ISRC = value
|
||||||
case "LYRICS", "UNSYNCEDLYRICS":
|
case "LYRICS", "UNSYNCEDLYRICS":
|
||||||
@@ -418,10 +425,10 @@ func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem {
|
|||||||
addItem("Year", metadata.Year)
|
addItem("Year", metadata.Year)
|
||||||
}
|
}
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
addItem("Track", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
|
addItem("Track", strconv.Itoa(metadata.TrackNumber))
|
||||||
}
|
}
|
||||||
if metadata.DiscNumber > 0 {
|
if metadata.DiscNumber > 0 {
|
||||||
addItem("Disc", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
|
addItem("Disc", strconv.Itoa(metadata.DiscNumber))
|
||||||
}
|
}
|
||||||
addItem("ISRC", metadata.ISRC)
|
addItem("ISRC", metadata.ISRC)
|
||||||
addItem("Lyrics", metadata.Lyrics)
|
addItem("Lyrics", metadata.Lyrics)
|
||||||
@@ -446,7 +453,7 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
|||||||
"artist": "ARTIST",
|
"artist": "ARTIST",
|
||||||
"album": "ALBUM",
|
"album": "ALBUM",
|
||||||
"album_artist": "ALBUM ARTIST",
|
"album_artist": "ALBUM ARTIST",
|
||||||
"date": "DATE",
|
"date": "YEAR",
|
||||||
"genre": "GENRE",
|
"genre": "GENRE",
|
||||||
"track_number": "TRACK",
|
"track_number": "TRACK",
|
||||||
"disc_number": "DISC",
|
"disc_number": "DISC",
|
||||||
@@ -468,7 +475,7 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Some fields have reader aliases that must also be cleared when the
|
// Some fields have reader aliases that must also be cleared when the
|
||||||
// canonical key is updated (e.g. DATE writer ↔ DATE/YEAR reader,
|
// canonical key is updated (e.g. "Year" writer ↔ DATE/YEAR reader,
|
||||||
// DISC ↔ DISCNUMBER, TRACK ↔ TRACKNUMBER, "ALBUM ARTIST" ↔ ALBUMARTIST,
|
// DISC ↔ DISCNUMBER, TRACK ↔ TRACKNUMBER, "ALBUM ARTIST" ↔ ALBUMARTIST,
|
||||||
// LABEL ↔ PUBLISHER, LYRICS ↔ UNSYNCEDLYRICS).
|
// LABEL ↔ PUBLISHER, LYRICS ↔ UNSYNCEDLYRICS).
|
||||||
if _, present := fields["date"]; present {
|
if _, present := fields["date"]; present {
|
||||||
@@ -477,15 +484,9 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
|||||||
if _, present := fields["disc_number"]; present {
|
if _, present := fields["disc_number"]; present {
|
||||||
result["DISCNUMBER"] = struct{}{}
|
result["DISCNUMBER"] = struct{}{}
|
||||||
}
|
}
|
||||||
if _, present := fields["disc_total"]; present {
|
|
||||||
result["DISCNUMBER"] = struct{}{}
|
|
||||||
}
|
|
||||||
if _, present := fields["track_number"]; present {
|
if _, present := fields["track_number"]; present {
|
||||||
result["TRACKNUMBER"] = struct{}{}
|
result["TRACKNUMBER"] = struct{}{}
|
||||||
}
|
}
|
||||||
if _, present := fields["track_total"]; present {
|
|
||||||
result["TRACKNUMBER"] = struct{}{}
|
|
||||||
}
|
|
||||||
if _, present := fields["album_artist"]; present {
|
if _, present := fields["album_artist"]; present {
|
||||||
result["ALBUMARTIST"] = struct{}{}
|
result["ALBUMARTIST"] = struct{}{}
|
||||||
}
|
}
|
||||||
@@ -508,6 +509,7 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
|||||||
// deletion: the caller sends an empty value which is not serialized into
|
// deletion: the caller sends an empty value which is not serialized into
|
||||||
// newItems, but the old value must still be dropped.
|
// newItems, but the old value must still be dropped.
|
||||||
func MergeAPEItems(existing, newItems []APETagItem, overrideKeys map[string]struct{}) []APETagItem {
|
func MergeAPEItems(existing, newItems []APETagItem, overrideKeys map[string]struct{}) []APETagItem {
|
||||||
|
// Build a set of keys being updated (upper-case for case-insensitive match)
|
||||||
combined := make(map[string]struct{}, len(newItems)+len(overrideKeys))
|
combined := make(map[string]struct{}, len(newItems)+len(overrideKeys))
|
||||||
for k := range overrideKeys {
|
for k := range overrideKeys {
|
||||||
combined[strings.ToUpper(k)] = struct{}{}
|
combined[strings.ToUpper(k)] = struct{}{}
|
||||||
@@ -535,6 +537,7 @@ func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
|
|||||||
return nil, fmt.Errorf("file too small for APE tag")
|
return nil, fmt.Errorf("file too small for APE tag")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try footer at end of file
|
||||||
footer := make([]byte, apeTagHeaderSize)
|
footer := make([]byte, apeTagHeaderSize)
|
||||||
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
|
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
|
||||||
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAPETagReadWriteMergeAndMetadataConversion(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "sample.ape")
|
|
||||||
if err := os.WriteFile(path, []byte("audio-data"), 0600); err != nil {
|
|
||||||
t.Fatalf("write sample: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := &AudioMetadata{
|
|
||||||
Title: "Song",
|
|
||||||
Artist: "Artist",
|
|
||||||
Album: "Album",
|
|
||||||
AlbumArtist: "Album Artist",
|
|
||||||
Genre: "Pop",
|
|
||||||
Date: "2026",
|
|
||||||
TrackNumber: 3,
|
|
||||||
TotalTracks: 12,
|
|
||||||
DiscNumber: 1,
|
|
||||||
TotalDiscs: 2,
|
|
||||||
ISRC: "USRC17607839",
|
|
||||||
Lyrics: "lyrics",
|
|
||||||
Label: "Label",
|
|
||||||
Copyright: "Copyright",
|
|
||||||
Composer: "Composer",
|
|
||||||
Comment: "Comment",
|
|
||||||
ReplayGainTrackGain: "-6.50 dB",
|
|
||||||
ReplayGainTrackPeak: "0.98",
|
|
||||||
ReplayGainAlbumGain: "-5.00 dB",
|
|
||||||
ReplayGainAlbumPeak: "0.99",
|
|
||||||
}
|
|
||||||
items := AudioMetadataToAPEItems(metadata)
|
|
||||||
if len(items) == 0 {
|
|
||||||
t.Fatal("expected APE items")
|
|
||||||
}
|
|
||||||
|
|
||||||
tag := &APETag{Items: append(items, APETagItem{Key: "Custom", Value: "Keep"})}
|
|
||||||
if err := WriteAPETags(path, tag); err != nil {
|
|
||||||
t.Fatalf("WriteAPETags: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
readTag, err := ReadAPETags(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadAPETags: %v", err)
|
|
||||||
}
|
|
||||||
if readTag.Version != apeTagVersion2 {
|
|
||||||
t.Fatalf("version = %d", readTag.Version)
|
|
||||||
}
|
|
||||||
readMetadata := APETagToAudioMetadata(readTag)
|
|
||||||
if readMetadata.Title != "Song" || readMetadata.TrackNumber != 3 || readMetadata.TotalTracks != 12 {
|
|
||||||
t.Fatalf("metadata = %#v", readMetadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
readerTag, err := ReadAPETagsFromReader(bytes.NewReader(mustReadFile(t, path)), int64(len(mustReadFile(t, path))))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadAPETagsFromReader: %v", err)
|
|
||||||
}
|
|
||||||
if len(readerTag.Items) != len(readTag.Items) {
|
|
||||||
t.Fatalf("reader items = %d, file items = %d", len(readerTag.Items), len(readTag.Items))
|
|
||||||
}
|
|
||||||
|
|
||||||
override := apeKeysFromFields(map[string]string{"title": "", "lyrics": "", "disc_total": ""})
|
|
||||||
merged := MergeAPEItems(readTag.Items, []APETagItem{{Key: "Title", Value: "New Song"}}, override)
|
|
||||||
mergedMeta := APETagToAudioMetadata(&APETag{Items: merged})
|
|
||||||
if mergedMeta.Title != "New Song" {
|
|
||||||
t.Fatalf("merged title = %q", mergedMeta.Title)
|
|
||||||
}
|
|
||||||
if mergedMeta.Lyrics != "" {
|
|
||||||
t.Fatalf("expected lyrics cleared, got %q", mergedMeta.Lyrics)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := WriteAPETags(path, &APETag{Items: []APETagItem{{Key: "Title", Value: "Replacement"}}}); err != nil {
|
|
||||||
t.Fatalf("replace APE tags: %v", err)
|
|
||||||
}
|
|
||||||
replaced, err := ReadAPETags(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read replacement: %v", err)
|
|
||||||
}
|
|
||||||
if got := APETagToAudioMetadata(replaced).Title; got != "Replacement" {
|
|
||||||
t.Fatalf("replacement title = %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := marshalAPETag(nil); err == nil {
|
|
||||||
t.Fatal("expected empty tag error")
|
|
||||||
}
|
|
||||||
if _, err := ReadAPETags(filepath.Join(dir, "missing.ape")); err == nil {
|
|
||||||
t.Fatal("expected missing file error")
|
|
||||||
}
|
|
||||||
if _, err := ReadAPETagsFromReader(bytes.NewReader([]byte("short")), 5); err == nil {
|
|
||||||
t.Fatal("expected small reader error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPETagInvalidFooterBranches(t *testing.T) {
|
|
||||||
footer := buildAPEHeaderFooter(9999, apeTagHeaderSize, 1, 0)
|
|
||||||
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
|
||||||
t.Fatal("expected unsupported version")
|
|
||||||
}
|
|
||||||
|
|
||||||
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize-1, 1, 0)
|
|
||||||
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
|
||||||
t.Fatal("expected small tag size")
|
|
||||||
}
|
|
||||||
|
|
||||||
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize, 1001, 0)
|
|
||||||
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
|
||||||
t.Fatal("expected too many items")
|
|
||||||
}
|
|
||||||
|
|
||||||
footer = buildAPEHeaderFooter(apeTagVersion2, apeTagHeaderSize, 1, apeTagFlagHeader)
|
|
||||||
if _, err := parseAPETagFromFooter(bytes.NewReader(footer), int64(len(footer)), 0, footer); err == nil {
|
|
||||||
t.Fatal("expected header flag error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,9 +21,7 @@ type AudioMetadata struct {
|
|||||||
Year string
|
Year string
|
||||||
Date string
|
Date string
|
||||||
TrackNumber int
|
TrackNumber int
|
||||||
TotalTracks int
|
|
||||||
DiscNumber int
|
DiscNumber int
|
||||||
TotalDiscs int
|
|
||||||
ISRC string
|
ISRC string
|
||||||
Lyrics string
|
Lyrics string
|
||||||
Label string
|
Label string
|
||||||
@@ -175,9 +173,9 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
|||||||
case "TCO":
|
case "TCO":
|
||||||
metadata.Genre = cleanGenre(value)
|
metadata.Genre = cleanGenre(value)
|
||||||
case "TRK":
|
case "TRK":
|
||||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
metadata.TrackNumber = parseTrackNumber(value)
|
||||||
case "TPA":
|
case "TPA":
|
||||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
metadata.DiscNumber = parseTrackNumber(value)
|
||||||
case "TCM":
|
case "TCM":
|
||||||
metadata.Composer = value
|
metadata.Composer = value
|
||||||
case "TPB":
|
case "TPB":
|
||||||
@@ -294,9 +292,9 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
|||||||
case "TCON":
|
case "TCON":
|
||||||
metadata.Genre = cleanGenre(value)
|
metadata.Genre = cleanGenre(value)
|
||||||
case "TRCK":
|
case "TRCK":
|
||||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
metadata.TrackNumber = parseTrackNumber(value)
|
||||||
case "TPOS":
|
case "TPOS":
|
||||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
metadata.DiscNumber = parseTrackNumber(value)
|
||||||
case "TSRC":
|
case "TSRC":
|
||||||
metadata.ISRC = value
|
metadata.ISRC = value
|
||||||
case "TCOM":
|
case "TCOM":
|
||||||
@@ -582,26 +580,12 @@ func cleanGenre(genre string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func parseTrackNumber(s string) int {
|
func parseTrackNumber(s string) int {
|
||||||
num, _ := parseIndexPair(s)
|
|
||||||
return num
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseIndexPair(s string) (int, int) {
|
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if s == "" {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
first := s
|
|
||||||
second := ""
|
|
||||||
if idx := strings.Index(s, "/"); idx > 0 {
|
if idx := strings.Index(s, "/"); idx > 0 {
|
||||||
first = s[:idx]
|
s = s[:idx]
|
||||||
second = s[idx+1:]
|
|
||||||
}
|
}
|
||||||
|
num, _ := strconv.Atoi(s)
|
||||||
num, _ := strconv.Atoi(strings.TrimSpace(first))
|
return num
|
||||||
total, _ := strconv.Atoi(strings.TrimSpace(second))
|
|
||||||
return num, total
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeUnsync(data []byte) []byte {
|
func removeUnsync(data []byte) []byte {
|
||||||
@@ -1053,9 +1037,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
|||||||
case "GENRE":
|
case "GENRE":
|
||||||
metadata.Genre = value
|
metadata.Genre = value
|
||||||
case "TRACKNUMBER", "TRACK":
|
case "TRACKNUMBER", "TRACK":
|
||||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
metadata.TrackNumber = parseTrackNumber(value)
|
||||||
case "DISCNUMBER", "DISC":
|
case "DISCNUMBER", "DISC":
|
||||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
metadata.DiscNumber = parseTrackNumber(value)
|
||||||
case "ISRC":
|
case "ISRC":
|
||||||
metadata.ISRC = value
|
metadata.ISRC = value
|
||||||
case "COMPOSER":
|
case "COMPOSER":
|
||||||
@@ -1624,9 +1608,6 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
|
|||||||
}
|
}
|
||||||
return data, mimeType, nil
|
return data, mimeType, nil
|
||||||
|
|
||||||
case ".wav", ".aiff", ".aif", ".aifc":
|
|
||||||
return extractWAVAIFFCover(filePath)
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,517 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/binary"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAudioMetadataID3ParsingBranches(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "tagged.mp3")
|
|
||||||
tag := buildID3v23Tag(
|
|
||||||
id3TextFrame("TIT2", "Title"),
|
|
||||||
id3TextFrame("TPE1", "Artist"),
|
|
||||||
id3TextFrame("TPE2", "Album Artist"),
|
|
||||||
id3TextFrame("TALB", "Album"),
|
|
||||||
id3TextFrame("TDRC", "2026-05-04"),
|
|
||||||
id3TextFrame("TCON", "(13)Pop"),
|
|
||||||
id3TextFrame("TRCK", "4/12"),
|
|
||||||
id3TextFrame("TPOS", "1/2"),
|
|
||||||
id3TextFrame("TSRC", "USRC17607839"),
|
|
||||||
id3TextFrame("TCOM", "Composer"),
|
|
||||||
id3TextFrame("TPUB", "Label"),
|
|
||||||
id3TextFrame("TCOP", "Copyright"),
|
|
||||||
id3CommentFrame("COMM", "Comment"),
|
|
||||||
id3CommentFrame("USLT", "Lyrics"),
|
|
||||||
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_GAIN", "-6.50 dB"),
|
|
||||||
id3UserTextFrame("TXXX", "REPLAYGAIN_TRACK_PEAK", "0.98"),
|
|
||||||
)
|
|
||||||
if err := os.WriteFile(path, append(tag, []byte("audio")...), 0600); err != nil {
|
|
||||||
t.Fatalf("write ID3v2: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
meta, err := ReadID3Tags(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadID3Tags: %v", err)
|
|
||||||
}
|
|
||||||
if meta.Title != "Title" || meta.TrackNumber != 4 || meta.TotalTracks != 12 || meta.Genre != "Pop" {
|
|
||||||
t.Fatalf("metadata = %#v", meta)
|
|
||||||
}
|
|
||||||
if meta.Comment != "Comment" || meta.Lyrics != "Lyrics" || meta.ReplayGainTrackGain == "" {
|
|
||||||
t.Fatalf("metadata comments/lyrics/replaygain = %#v", meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
id3v1Path := filepath.Join(dir, "id3v1.mp3")
|
|
||||||
if err := os.WriteFile(id3v1Path, append([]byte("audio"), buildID3v1Tag("V1 Title", "V1 Artist", "V1 Album", "1999", 7, 13)...), 0600); err != nil {
|
|
||||||
t.Fatalf("write ID3v1: %v", err)
|
|
||||||
}
|
|
||||||
v1, err := ReadID3Tags(id3v1Path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadID3Tags v1: %v", err)
|
|
||||||
}
|
|
||||||
if v1.Title != "V1 Title" || v1.Artist != "V1 Artist" || v1.Genre == "" {
|
|
||||||
t.Fatalf("v1 = %#v", v1)
|
|
||||||
}
|
|
||||||
|
|
||||||
v22Path := filepath.Join(dir, "id3v22.mp3")
|
|
||||||
v22 := buildID3v22Tag(
|
|
||||||
id3v22TextFrame("TT2", "V22 Title"),
|
|
||||||
id3v22TextFrame("TP1", "V22 Artist"),
|
|
||||||
id3v22TextFrame("TRK", "2/5"),
|
|
||||||
id3v22CommentFrame("ULT", "V22 Lyrics"),
|
|
||||||
)
|
|
||||||
if err := os.WriteFile(v22Path, append(v22, []byte("audio")...), 0600); err != nil {
|
|
||||||
t.Fatalf("write ID3v2.2: %v", err)
|
|
||||||
}
|
|
||||||
v22Meta, err := ReadID3Tags(v22Path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadID3Tags v2.2: %v", err)
|
|
||||||
}
|
|
||||||
if v22Meta.Title != "V22 Title" || v22Meta.Artist != "V22 Artist" || v22Meta.Lyrics != "V22 Lyrics" {
|
|
||||||
t.Fatalf("v22 = %#v", v22Meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := decodeUTF16([]byte{0xff, 0xfe, 'H', 0, 'i', 0}); got != "Hi" {
|
|
||||||
t.Fatalf("decodeUTF16 = %q", got)
|
|
||||||
}
|
|
||||||
if got := decodeUTF16BE([]byte{0, 'O', 0, 'K'}); got != "OK" {
|
|
||||||
t.Fatalf("decodeUTF16BE = %q", got)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
if got := extendedHeaderSize([]byte{0, 0, 0, 6, 0, 0, 0, 0, 0, 0}, 3); got != 10 {
|
|
||||||
t.Fatalf("extendedHeaderSize = %d", got)
|
|
||||||
}
|
|
||||||
if got := syncsafeToInt([]byte{0, 0, 2, 0}); got != 256 {
|
|
||||||
t.Fatalf("syncsafe = %d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAudioMetadataCoverAndQualityHelpers(t *testing.T) {
|
|
||||||
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0}
|
|
||||||
if detectCoverMIME("cover.jpg", png) != "image/png" || detectCoverMIME("cover.webp", []byte("RIFFxxxxWEBPdata")) != "image/webp" {
|
|
||||||
t.Fatal("cover MIME detection mismatch")
|
|
||||||
}
|
|
||||||
if _, err := buildPictureBlock("", nil); err == nil {
|
|
||||||
t.Fatal("expected empty picture block error")
|
|
||||||
}
|
|
||||||
|
|
||||||
apic := append([]byte{3}, []byte("image/png\x00")...)
|
|
||||||
apic = append(apic, 3, 0)
|
|
||||||
apic = append(apic, png...)
|
|
||||||
image, mime := parseAPICFrame(apic, 3)
|
|
||||||
if mime != "image/png" || !bytes.Equal(image, png) {
|
|
||||||
t.Fatalf("APIC = %s/%v", mime, image)
|
|
||||||
}
|
|
||||||
pic := append([]byte{0}, []byte("PNG")...)
|
|
||||||
pic = append(pic, 3, 0)
|
|
||||||
pic = append(pic, png...)
|
|
||||||
image, mime = parseAPICFrame(pic, 2)
|
|
||||||
if mime != "image/png" || !bytes.Equal(image, png) {
|
|
||||||
t.Fatalf("PIC = %s/%v", mime, image)
|
|
||||||
}
|
|
||||||
|
|
||||||
frame := make([]byte, 10)
|
|
||||||
copy(frame[:4], "APIC")
|
|
||||||
binary.BigEndian.PutUint32(frame[4:8], uint32(len(apic)))
|
|
||||||
tag := append(frame, apic...)
|
|
||||||
header := []byte{'I', 'D', '3', 3, 0, 0, byte(len(tag) >> 21), byte(len(tag) >> 14), byte(len(tag) >> 7), byte(len(tag))}
|
|
||||||
mp3CoverPath := filepath.Join(t.TempDir(), "cover.mp3")
|
|
||||||
if err := os.WriteFile(mp3CoverPath, append(append(header, tag...), []byte("audio")...), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
extracted, extractedMIME, err := extractMP3CoverArt(mp3CoverPath)
|
|
||||||
if err != nil || extractedMIME != "image/png" || !bytes.Equal(extracted, png) {
|
|
||||||
t.Fatalf("extractMP3CoverArt = %s/%v/%v", extractedMIME, extracted, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var picture bytes.Buffer
|
|
||||||
binary.Write(&picture, binary.BigEndian, uint32(3))
|
|
||||||
binary.Write(&picture, binary.BigEndian, uint32(len("image/png")))
|
|
||||||
picture.WriteString("image/png")
|
|
||||||
binary.Write(&picture, binary.BigEndian, uint32(0))
|
|
||||||
binary.Write(&picture, binary.BigEndian, uint32(1))
|
|
||||||
binary.Write(&picture, binary.BigEndian, uint32(1))
|
|
||||||
binary.Write(&picture, binary.BigEndian, uint32(32))
|
|
||||||
binary.Write(&picture, binary.BigEndian, uint32(0))
|
|
||||||
binary.Write(&picture, binary.BigEndian, uint32(len(png)))
|
|
||||||
picture.Write(png)
|
|
||||||
flacImage, flacMIME := parseFLACPictureBlock(picture.Bytes())
|
|
||||||
if flacMIME != "image/png" || !bytes.Equal(flacImage, png) {
|
|
||||||
t.Fatalf("FLAC picture = %s/%v", flacMIME, flacImage)
|
|
||||||
}
|
|
||||||
|
|
||||||
comment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture.Bytes())
|
|
||||||
var vorbis bytes.Buffer
|
|
||||||
binary.Write(&vorbis, binary.LittleEndian, uint32(6))
|
|
||||||
vorbis.WriteString("vendor")
|
|
||||||
binary.Write(&vorbis, binary.LittleEndian, uint32(1))
|
|
||||||
binary.Write(&vorbis, binary.LittleEndian, uint32(len(comment)))
|
|
||||||
vorbis.WriteString(comment)
|
|
||||||
commentImage, commentMIME := extractPictureFromVorbisComments(vorbis.Bytes())
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
if detectOggStreamType([][]byte{append([]byte{1}, []byte("vorbisxxxx")...)}) != oggStreamVorbis {
|
|
||||||
t.Fatal("expected vorbis stream")
|
|
||||||
}
|
|
||||||
|
|
||||||
mp3Path := filepath.Join(t.TempDir(), "quality.mp3")
|
|
||||||
audio := append([]byte{0xFF, 0xFB, 0x90, 0x64}, bytes.Repeat([]byte{0}, 2000)...)
|
|
||||||
if err := os.WriteFile(mp3Path, audio, 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
quality, err := GetMP3Quality(mp3Path)
|
|
||||||
if err != nil || quality.SampleRate != 44100 || quality.Bitrate != 128000 {
|
|
||||||
t.Fatalf("MP3 quality = %#v/%v", quality, err)
|
|
||||||
}
|
|
||||||
if _, _, err := extractMP3CoverArt(filepath.Join(t.TempDir(), "missing.mp3")); err == nil {
|
|
||||||
t.Fatal("expected missing MP3 cover error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestM4AMetadataAtomHelpers(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "tagged.m4a")
|
|
||||||
cover := []byte{0xFF, 0xD8, 0xFF, 0x00}
|
|
||||||
ilstPayload := []byte{}
|
|
||||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9nam", "M4A Title")...)
|
|
||||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9ART", "M4A Artist")...)
|
|
||||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9alb", "M4A Album")...)
|
|
||||||
ilstPayload = append(ilstPayload, buildM4ATextTag("aART", "Album Artist")...)
|
|
||||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9day", "2026")...)
|
|
||||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9gen", "Pop")...)
|
|
||||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9wrt", "Composer")...)
|
|
||||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9cmt", "[ti:Comment Lyrics]")...)
|
|
||||||
ilstPayload = append(ilstPayload, buildM4ATextTag("cprt", "Copyright")...)
|
|
||||||
ilstPayload = append(ilstPayload, buildM4ATextTag("\xa9lyr", "[00:00.00]M4A Lyrics")...)
|
|
||||||
ilstPayload = append(ilstPayload, buildM4AIndexTag("trkn", 3, 12)...)
|
|
||||||
ilstPayload = append(ilstPayload, buildM4AIndexTag("disk", 1, 2)...)
|
|
||||||
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("ISRC", "USRC17607839")...)
|
|
||||||
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("LABEL", "Label")...)
|
|
||||||
ilstPayload = append(ilstPayload, buildM4AFreeformAtom("REPLAYGAIN_TRACK_GAIN", "-6.50 dB")...)
|
|
||||||
ilstPayload = append(ilstPayload, buildM4AAtom("covr", buildM4AAtom("data", append([]byte{0, 0, 0, 13, 0, 0, 0, 0}, cover...)))...)
|
|
||||||
fileData := buildM4AFileWithIlst(ilstPayload, true)
|
|
||||||
if err := os.WriteFile(path, fileData, 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
meta, err := ReadM4ATags(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ReadM4ATags: %v", err)
|
|
||||||
}
|
|
||||||
if meta.Title != "M4A Title" || meta.Artist != "M4A Artist" || meta.TrackNumber != 3 || meta.TotalTracks != 12 || meta.ISRC != "USRC17607839" {
|
|
||||||
t.Fatalf("M4A metadata = %#v", meta)
|
|
||||||
}
|
|
||||||
if lyrics, err := extractLyricsFromM4A(path); err != nil || !strings.Contains(lyrics, "M4A Lyrics") {
|
|
||||||
t.Fatalf("extractLyricsFromM4A = %q/%v", lyrics, err)
|
|
||||||
}
|
|
||||||
if image, err := extractCoverFromM4A(path); err != nil || !bytes.Equal(image, cover) {
|
|
||||||
t.Fatalf("extractCoverFromM4A = %#v/%v", image, err)
|
|
||||||
}
|
|
||||||
if pathInfo, err := func() (m4aMetadataPath, error) {
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return m4aMetadataPath{}, err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
info, _ := f.Stat()
|
|
||||||
return findM4AMetadataPath(f, info.Size())
|
|
||||||
}(); err != nil || pathInfo.udta == nil {
|
|
||||||
t.Fatalf("findM4AMetadataPath = %#v/%v", pathInfo, err)
|
|
||||||
}
|
|
||||||
if err := EditM4AReplayGain(path, map[string]string{"replaygain_track_gain": "-5.00 dB", "replaygain_track_peak": "0.98"}); err != nil {
|
|
||||||
t.Fatalf("EditM4AReplayGain: %v", err)
|
|
||||||
}
|
|
||||||
edited, err := ReadM4ATags(path)
|
|
||||||
if err != nil || edited.ReplayGainTrackGain != "-5.00 dB" || edited.ReplayGainTrackPeak != "0.98" {
|
|
||||||
t.Fatalf("edited M4A = %#v/%v", edited, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
noUdtaPath := filepath.Join(dir, "noudta.m4a")
|
|
||||||
if err := os.WriteFile(noUdtaPath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "No Udta"), false), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if meta, err := ReadM4ATags(noUdtaPath); err != nil || meta.Title != "No Udta" {
|
|
||||||
t.Fatalf("ReadM4ATags no udta = %#v/%v", meta, err)
|
|
||||||
}
|
|
||||||
if _, err := ReadM4ATags(filepath.Join(dir, "missing.m4a")); err == nil {
|
|
||||||
t.Fatal("expected missing M4A error")
|
|
||||||
}
|
|
||||||
emptyM4A := filepath.Join(dir, "empty.m4a")
|
|
||||||
if err := os.WriteFile(emptyM4A, buildM4AFileWithIlst(nil, true), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := ReadM4ATags(emptyM4A); err == nil {
|
|
||||||
t.Fatal("expected empty M4A tags error")
|
|
||||||
}
|
|
||||||
if _, err := extractCoverFromM4A(emptyM4A); err == nil {
|
|
||||||
t.Fatal("expected missing M4A cover error")
|
|
||||||
}
|
|
||||||
if _, err := extractLyricsFromM4A(emptyM4A); err == nil {
|
|
||||||
t.Fatal("expected missing M4A lyrics error")
|
|
||||||
}
|
|
||||||
|
|
||||||
sidecarAudio := filepath.Join(dir, "sidecar.mp3")
|
|
||||||
if err := os.WriteFile(sidecarAudio, []byte("audio"), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "sidecar.lrc"), []byte(" [00:00.00]Sidecar "), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if lyrics, err := extractLyricsFromSidecarLRC(sidecarAudio); err != nil || !strings.Contains(lyrics, "Sidecar") {
|
|
||||||
t.Fatalf("sidecar lyrics = %q/%v", lyrics, err)
|
|
||||||
}
|
|
||||||
if !looksLikeEmbeddedLyrics("[ti:Song]") || !looksLikeEmbeddedLyrics("[00:00.00]Line\n[00:01.00]Next") || looksLikeEmbeddedLyrics("plain") {
|
|
||||||
t.Fatal("embedded lyric heuristic mismatch")
|
|
||||||
}
|
|
||||||
if formatIndexValue(3, 12) != "3/12" || formatIndexValue(3, 0) != "3" || formatIndexValue(0, 12) != "" {
|
|
||||||
t.Fatal("formatIndexValue mismatch")
|
|
||||||
}
|
|
||||||
if parsePositiveInt(" 42 ") != 42 || parsePositiveInt("bad") != 0 {
|
|
||||||
t.Fatal("parsePositiveInt mismatch")
|
|
||||||
}
|
|
||||||
if !hasMapKey(map[string]string{"x": "y"}, "x") {
|
|
||||||
t.Fatal("expected map key")
|
|
||||||
}
|
|
||||||
if _, ok := parseReplayGainDb("-6.50 dB"); !ok {
|
|
||||||
t.Fatal("expected ReplayGain dB parse")
|
|
||||||
}
|
|
||||||
if _, ok := parseReplayGainPeak("0.98"); !ok {
|
|
||||||
t.Fatal("expected ReplayGain peak parse")
|
|
||||||
}
|
|
||||||
if norm := buildITunNORMTag("-6.50 dB", "0.98"); norm == "" {
|
|
||||||
t.Fatal("expected iTunNORM")
|
|
||||||
}
|
|
||||||
if fields := collectM4AReplayGainFields(map[string]string{"replaygain_track_gain": "-6 dB", "replaygain_track_peak": "0.9"}); fields["iTunNORM"] == "" {
|
|
||||||
t.Fatalf("ReplayGain fields = %#v", fields)
|
|
||||||
}
|
|
||||||
|
|
||||||
qualityPath := filepath.Join(dir, "quality-alac.m4a")
|
|
||||||
mvhd := make([]byte, 20)
|
|
||||||
binary.BigEndian.PutUint32(mvhd[12:16], 1000)
|
|
||||||
binary.BigEndian.PutUint32(mvhd[16:20], 180000)
|
|
||||||
sampleEntry := make([]byte, 32)
|
|
||||||
copy(sampleEntry[0:4], "alac")
|
|
||||||
binary.BigEndian.PutUint16(sampleEntry[22:24], 24)
|
|
||||||
sampleEntry[28] = 0xAC
|
|
||||||
sampleEntry[29] = 0x44
|
|
||||||
alacConfig := make([]byte, 24)
|
|
||||||
alacConfig[5] = 24
|
|
||||||
binary.BigEndian.PutUint32(alacConfig[20:24], 44100)
|
|
||||||
alacEntryPayload := append(append([]byte{}, sampleEntry[4:]...), buildM4AAtom("alac", alacConfig)...)
|
|
||||||
qualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), buildM4AAtom("alac", alacEntryPayload)...))...)
|
|
||||||
if err := os.WriteFile(qualityPath, qualityFile, 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if quality, err := GetM4AQuality(qualityPath); err != nil || quality.BitDepth != 24 || quality.SampleRate != 44100 || quality.Duration != 180 {
|
|
||||||
t.Fatalf("GetM4AQuality = %#v/%v", quality, err)
|
|
||||||
}
|
|
||||||
if quality, err := GetAudioQuality(qualityPath); err != nil || quality.SampleRate != 44100 {
|
|
||||||
t.Fatalf("GetAudioQuality M4A = %#v/%v", quality, err)
|
|
||||||
}
|
|
||||||
aacQualityPath := filepath.Join(dir, "quality-aac.m4a")
|
|
||||||
copy(sampleEntry[0:4], "mp4a")
|
|
||||||
aacQualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), sampleEntry...))...)
|
|
||||||
if err := os.WriteFile(aacQualityPath, aacQualityFile, 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if quality, err := GetM4AQuality(aacQualityPath); err != nil || quality.BitDepth != 0 || quality.SampleRate != 44100 || quality.Duration != 180 {
|
|
||||||
t.Fatalf("GetM4AQuality AAC = %#v/%v", quality, err)
|
|
||||||
}
|
|
||||||
eac3QualityPath := filepath.Join(dir, "quality-eac3.m4a")
|
|
||||||
zeroMvhd := make([]byte, 20)
|
|
||||||
eac3SampleEntry := make([]byte, 32)
|
|
||||||
copy(eac3SampleEntry[0:4], "ec-3")
|
|
||||||
eac3SampleEntry[28] = 0xBB
|
|
||||||
eac3SampleEntry[29] = 0x80
|
|
||||||
mdhd := make([]byte, 20)
|
|
||||||
binary.BigEndian.PutUint32(mdhd[12:16], 48000)
|
|
||||||
binary.BigEndian.PutUint32(mdhd[16:20], 48000*123)
|
|
||||||
eac3QualityFile := append(
|
|
||||||
buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")),
|
|
||||||
buildM4AAtom("moov", append(
|
|
||||||
append(buildM4AAtom("mvhd", zeroMvhd), buildM4AAtom("trak", buildM4AAtom("mdia", buildM4AAtom("mdhd", mdhd)))...),
|
|
||||||
eac3SampleEntry...,
|
|
||||||
))...,
|
|
||||||
)
|
|
||||||
if err := os.WriteFile(eac3QualityPath, eac3QualityFile, 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if quality, err := GetM4AQuality(eac3QualityPath); err != nil || quality.Codec != "eac3" || quality.Duration != 123 {
|
|
||||||
t.Fatalf("GetM4AQuality EAC3 mdhd fallback = %#v/%v", quality, err)
|
|
||||||
}
|
|
||||||
if _, _, ok := parseALACSpecificConfig(make([]byte, 4)); ok {
|
|
||||||
t.Fatal("short ALAC config should not parse")
|
|
||||||
}
|
|
||||||
alac := make([]byte, 24)
|
|
||||||
alac[5] = 16
|
|
||||||
binary.BigEndian.PutUint32(alac[20:24], 48000)
|
|
||||||
if depth, rate, ok := parseALACSpecificConfig(alac); !ok || depth != 16 || rate != 48000 {
|
|
||||||
t.Fatalf("ALAC config = %d/%d/%v", depth, rate, ok)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOggMetadataQualityAndCoverHelpers(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
opusHead := make([]byte, 19)
|
|
||||||
copy(opusHead[0:8], "OpusHead")
|
|
||||||
binary.LittleEndian.PutUint16(opusHead[10:12], 312)
|
|
||||||
binary.LittleEndian.PutUint32(opusHead[12:16], 48000)
|
|
||||||
|
|
||||||
var comments bytes.Buffer
|
|
||||||
binary.Write(&comments, binary.LittleEndian, uint32(6))
|
|
||||||
comments.WriteString("vendor")
|
|
||||||
entries := []string{
|
|
||||||
"TITLE=Ogg Title",
|
|
||||||
"ARTIST=Artist",
|
|
||||||
"ALBUMARTIST=Album Artist",
|
|
||||||
"TRACKNUMBER=2/9",
|
|
||||||
"DISCNUMBER=1/2",
|
|
||||||
"LYRICS=[00:00.00]Ogg Lyrics",
|
|
||||||
}
|
|
||||||
binary.Write(&comments, binary.LittleEndian, uint32(len(entries)))
|
|
||||||
for _, entry := range entries {
|
|
||||||
binary.Write(&comments, binary.LittleEndian, uint32(len(entry)))
|
|
||||||
comments.WriteString(entry)
|
|
||||||
}
|
|
||||||
opusTags := append([]byte("OpusTags"), comments.Bytes()...)
|
|
||||||
oggPath := filepath.Join(dir, "tagged.opus")
|
|
||||||
oggData := append(buildOggPage(0x02, 0, opusHead), buildOggPage(0x00, 48000+312, opusTags)...)
|
|
||||||
if err := os.WriteFile(oggPath, oggData, 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
quality, err := GetOggQuality(oggPath)
|
|
||||||
if err != nil || quality.SampleRate != 48000 || quality.Duration != 1 {
|
|
||||||
t.Fatalf("GetOggQuality = %#v/%v", quality, err)
|
|
||||||
}
|
|
||||||
meta, err := ReadOggVorbisComments(oggPath)
|
|
||||||
if err != nil || meta.Title != "Ogg Title" || meta.TrackNumber != 2 || meta.TotalTracks != 9 {
|
|
||||||
t.Fatalf("ReadOggVorbisComments = %#v/%v", meta, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
picture := buildTestFLACPictureBlock([]byte{0x89, 0x50, 0x4E, 0x47}, "image/png")
|
|
||||||
pictureComment := "METADATA_BLOCK_PICTURE=" + base64.StdEncoding.EncodeToString(picture)
|
|
||||||
var coverComments bytes.Buffer
|
|
||||||
binary.Write(&coverComments, binary.LittleEndian, uint32(6))
|
|
||||||
coverComments.WriteString("vendor")
|
|
||||||
binary.Write(&coverComments, binary.LittleEndian, uint32(1))
|
|
||||||
binary.Write(&coverComments, binary.LittleEndian, uint32(len(pictureComment)))
|
|
||||||
coverComments.WriteString(pictureComment)
|
|
||||||
coverPath := filepath.Join(dir, "cover.opus")
|
|
||||||
coverData := append(buildOggPage(0x02, 0, opusHead), buildOggPage(0x00, 48000+312, append([]byte("OpusTags"), coverComments.Bytes()...))...)
|
|
||||||
if err := os.WriteFile(coverPath, coverData, 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if image, mime, err := extractOggCoverArt(coverPath); err != nil || mime != "image/png" || len(image) == 0 {
|
|
||||||
t.Fatalf("extractOggCoverArt = %s/%#v/%v", mime, image, err)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
if data := mustReadFile(t, extractedCoverPath); len(data) == 0 {
|
|
||||||
t.Fatal("expected extracted cover data")
|
|
||||||
}
|
|
||||||
cachePath, err := SaveCoverToCacheWithHintAndKey(coverPath, "cover.opus", dir, "key")
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
badPath := filepath.Join(dir, "bad.ogg")
|
|
||||||
if err := os.WriteFile(badPath, []byte("bad"), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := GetOggQuality(badPath); err == nil {
|
|
||||||
t.Fatal("expected invalid Ogg quality error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildM4ADataPayload(payload []byte) []byte {
|
|
||||||
return append([]byte{0, 0, 0, 1, 0, 0, 0, 0}, payload...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildM4ATextTag(atomType, value string) []byte {
|
|
||||||
return buildM4AAtom(atomType, buildM4AAtom("data", buildM4ADataPayload([]byte(value))))
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildM4AIndexTag(atomType string, number, total int) []byte {
|
|
||||||
payload := []byte{0, 0, 0, byte(number), 0, byte(total), 0, 0}
|
|
||||||
return buildM4AAtom(atomType, buildM4AAtom("data", buildM4ADataPayload(payload)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildM4AFileWithIlst(ilstPayload []byte, withUdta bool) []byte {
|
|
||||||
ilst := buildM4AAtom("ilst", ilstPayload)
|
|
||||||
meta := buildM4AAtom("meta", append([]byte{0, 0, 0, 0}, ilst...))
|
|
||||||
moovPayload := meta
|
|
||||||
if withUdta {
|
|
||||||
moovPayload = buildM4AAtom("udta", meta)
|
|
||||||
}
|
|
||||||
return append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", moovPayload)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildOggPage(headerType byte, granule uint64, packet []byte) []byte {
|
|
||||||
header := make([]byte, 27)
|
|
||||||
copy(header[0:4], "OggS")
|
|
||||||
header[4] = 0
|
|
||||||
header[5] = headerType
|
|
||||||
binary.LittleEndian.PutUint64(header[6:14], granule)
|
|
||||||
header[26] = 1
|
|
||||||
return append(append(header, byte(len(packet))), packet...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildTestFLACPictureBlock(image []byte, mime string) []byte {
|
|
||||||
var picture bytes.Buffer
|
|
||||||
binary.Write(&picture, binary.BigEndian, uint32(3))
|
|
||||||
binary.Write(&picture, binary.BigEndian, uint32(len(mime)))
|
|
||||||
picture.WriteString(mime)
|
|
||||||
binary.Write(&picture, binary.BigEndian, uint32(0))
|
|
||||||
binary.Write(&picture, binary.BigEndian, uint32(1))
|
|
||||||
binary.Write(&picture, binary.BigEndian, uint32(1))
|
|
||||||
binary.Write(&picture, binary.BigEndian, uint32(32))
|
|
||||||
binary.Write(&picture, binary.BigEndian, uint32(0))
|
|
||||||
binary.Write(&picture, binary.BigEndian, uint32(len(image)))
|
|
||||||
picture.Write(image)
|
|
||||||
return picture.Bytes()
|
|
||||||
}
|
|
||||||
@@ -9,23 +9,14 @@ import (
|
|||||||
// ErrDownloadCancelled is returned when a download is cancelled by the user.
|
// ErrDownloadCancelled is returned when a download is cancelled by the user.
|
||||||
var ErrDownloadCancelled = errors.New("download cancelled")
|
var ErrDownloadCancelled = errors.New("download cancelled")
|
||||||
|
|
||||||
// ErrExtensionRequestCancelled is returned when a UI-driven extension request
|
|
||||||
// is superseded by a newer home/search request.
|
|
||||||
var ErrExtensionRequestCancelled = errors.New("extension request cancelled")
|
|
||||||
|
|
||||||
type cancelEntry struct {
|
type cancelEntry struct {
|
||||||
ctx context.Context
|
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
canceled bool
|
canceled bool
|
||||||
refs int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
cancelMu sync.Mutex
|
cancelMu sync.Mutex
|
||||||
cancelMap = make(map[string]*cancelEntry)
|
cancelMap = make(map[string]*cancelEntry)
|
||||||
|
|
||||||
extensionRequestCancelMu sync.Mutex
|
|
||||||
extensionRequestCancelMap = make(map[string]*cancelEntry)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func initDownloadCancel(itemID string) context.Context {
|
func initDownloadCancel(itemID string) context.Context {
|
||||||
@@ -36,25 +27,10 @@ func initDownloadCancel(itemID string) context.Context {
|
|||||||
cancelMu.Lock()
|
cancelMu.Lock()
|
||||||
defer cancelMu.Unlock()
|
defer cancelMu.Unlock()
|
||||||
|
|
||||||
if entry, ok := cancelMap[itemID]; ok {
|
|
||||||
if entry.ctx == nil {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
entry.ctx = ctx
|
|
||||||
entry.cancel = cancel
|
|
||||||
if entry.canceled && entry.cancel != nil {
|
|
||||||
entry.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entry.refs++
|
|
||||||
return entry.ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancelMap[itemID] = &cancelEntry{
|
cancelMap[itemID] = &cancelEntry{
|
||||||
ctx: ctx,
|
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
canceled: false,
|
canceled: false,
|
||||||
refs: 1,
|
|
||||||
}
|
}
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
@@ -97,86 +73,6 @@ func clearDownloadCancel(itemID string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancelMu.Lock()
|
cancelMu.Lock()
|
||||||
if entry, ok := cancelMap[itemID]; ok {
|
delete(cancelMap, itemID)
|
||||||
entry.refs--
|
|
||||||
if entry.refs <= 0 {
|
|
||||||
delete(cancelMap, itemID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cancelMu.Unlock()
|
cancelMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func initExtensionRequestCancel(requestID string) context.Context {
|
|
||||||
if requestID == "" {
|
|
||||||
return context.Background()
|
|
||||||
}
|
|
||||||
|
|
||||||
extensionRequestCancelMu.Lock()
|
|
||||||
defer extensionRequestCancelMu.Unlock()
|
|
||||||
|
|
||||||
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
|
||||||
if entry.ctx == nil {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
entry.ctx = ctx
|
|
||||||
entry.cancel = cancel
|
|
||||||
if entry.canceled && entry.cancel != nil {
|
|
||||||
entry.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entry.refs++
|
|
||||||
return entry.ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
extensionRequestCancelMap[requestID] = &cancelEntry{
|
|
||||||
ctx: ctx,
|
|
||||||
cancel: cancel,
|
|
||||||
canceled: false,
|
|
||||||
refs: 1,
|
|
||||||
}
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancelExtensionRequest(requestID string) {
|
|
||||||
if requestID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
extensionRequestCancelMu.Lock()
|
|
||||||
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
|
||||||
entry.canceled = true
|
|
||||||
if entry.cancel != nil {
|
|
||||||
entry.cancel()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
extensionRequestCancelMap[requestID] = &cancelEntry{canceled: true}
|
|
||||||
}
|
|
||||||
extensionRequestCancelMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func isExtensionRequestCancelled(requestID string) bool {
|
|
||||||
if requestID == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
extensionRequestCancelMu.Lock()
|
|
||||||
entry, ok := extensionRequestCancelMap[requestID]
|
|
||||||
canceled := ok && entry.canceled
|
|
||||||
extensionRequestCancelMu.Unlock()
|
|
||||||
return canceled
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearExtensionRequestCancel(requestID string) {
|
|
||||||
if requestID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
extensionRequestCancelMu.Lock()
|
|
||||||
if entry, ok := extensionRequestCancelMap[requestID]; ok {
|
|
||||||
entry.refs--
|
|
||||||
if entry.refs <= 0 {
|
|
||||||
delete(extensionRequestCancelMap, requestID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
extensionRequestCancelMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
|||||||
|
|
||||||
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
|
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
|
||||||
|
|
||||||
var qobuzSizeRegex = regexp.MustCompile(`_\d+\.jpg$`)
|
|
||||||
|
|
||||||
func convertSmallToMedium(imageURL string) string {
|
func convertSmallToMedium(imageURL string) string {
|
||||||
if strings.Contains(imageURL, spotifySize300) {
|
if strings.Contains(imageURL, spotifySize300) {
|
||||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||||
@@ -137,7 +135,7 @@ func upgradeQobuzCover(coverURL string) string {
|
|||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
upgraded := qobuzSizeRegex.ReplaceAllString(coverURL, "_max.jpg")
|
upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg")
|
||||||
if upgraded != coverURL {
|
if upgraded != coverURL {
|
||||||
GoLog("[Cover] Qobuz: upgraded to max resolution")
|
GoLog("[Cover] Qobuz: upgraded to max resolution")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,401 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/zip"
|
|
||||||
"bytes"
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newTestLoadedExtension(t *testing.T, types ...ExtensionType) *loadedExtension {
|
|
||||||
t.Helper()
|
|
||||||
dir := t.TempDir()
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "index.js"), []byte(testExtensionJS), 0600); err != nil {
|
|
||||||
t.Fatalf("write index.js: %v", err)
|
|
||||||
}
|
|
||||||
return &loadedExtension{
|
|
||||||
ID: "coverage-ext",
|
|
||||||
Manifest: &ExtensionManifest{
|
|
||||||
Name: "coverage-ext",
|
|
||||||
Description: "Coverage extension",
|
|
||||||
Version: "1.0.0",
|
|
||||||
Types: types,
|
|
||||||
Permissions: ExtensionPermissions{File: true, Network: []string{"example.test"}},
|
|
||||||
SearchBehavior: &SearchBehaviorConfig{
|
|
||||||
Enabled: true,
|
|
||||||
Placeholder: "Search coverage",
|
|
||||||
Primary: true,
|
|
||||||
Icon: "search",
|
|
||||||
},
|
|
||||||
URLHandler: &URLHandlerConfig{Enabled: true, Patterns: []string{"https://example.test/"}},
|
|
||||||
TrackMatching: &TrackMatchingConfig{CustomMatching: true},
|
|
||||||
PostProcessing: &PostProcessingConfig{
|
|
||||||
Enabled: true,
|
|
||||||
Hooks: []PostProcessingHook{{ID: "hook", Name: "Hook", DefaultEnabled: true, SupportedFormats: []string{"flac"}}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Enabled: true,
|
|
||||||
SourceDir: dir,
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const testExtensionJS = `
|
|
||||||
function track(id) {
|
|
||||||
return {
|
|
||||||
id: id,
|
|
||||||
name: "Track " + id,
|
|
||||||
artists: "Artist",
|
|
||||||
albumName: "Album",
|
|
||||||
albumArtist: "Album Artist",
|
|
||||||
durationMs: 180000,
|
|
||||||
coverUrl: "https://example.test/cover.jpg",
|
|
||||||
releaseDate: "2026-05-04",
|
|
||||||
trackNumber: 1,
|
|
||||||
totalTracks: 10,
|
|
||||||
discNumber: 1,
|
|
||||||
totalDiscs: 1,
|
|
||||||
isrc: "USRC17607839",
|
|
||||||
itemType: "track",
|
|
||||||
albumType: "album",
|
|
||||||
tidalId: "tidal-1",
|
|
||||||
qobuzId: "qobuz-1",
|
|
||||||
deezerId: "deezer-1",
|
|
||||||
spotifyId: "spotify:track:1",
|
|
||||||
externalLinks: { tidal: "https://tidal.example/1" },
|
|
||||||
label: "Label",
|
|
||||||
copyright: "Copyright",
|
|
||||||
genre: "Pop",
|
|
||||||
composer: "Composer",
|
|
||||||
audioQuality: "FLAC 24-bit",
|
|
||||||
audioModes: "DOLBY_ATMOS"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
registerExtension({
|
|
||||||
searchTracks: function(query, limit) {
|
|
||||||
return { tracks: [track("search-1")], total: 1 };
|
|
||||||
},
|
|
||||||
customSearch: function(query, options) {
|
|
||||||
var t = track("custom-1");
|
|
||||||
t.name = "Custom " + query;
|
|
||||||
return [t];
|
|
||||||
},
|
|
||||||
getHomeFeed: function() {
|
|
||||||
return [{ id: "home-1", title: "Home", tracks: [track("home-track")] }];
|
|
||||||
},
|
|
||||||
getBrowseCategories: function() {
|
|
||||||
return [{ id: "cat-1", title: "Category" }];
|
|
||||||
},
|
|
||||||
getTrack: function(id) {
|
|
||||||
return track(id);
|
|
||||||
},
|
|
||||||
getAlbum: function(id) {
|
|
||||||
return {
|
|
||||||
id: id,
|
|
||||||
name: "Album " + id,
|
|
||||||
artists: "Artist",
|
|
||||||
artistId: "artist-1",
|
|
||||||
coverUrl: "https://example.test/album.jpg",
|
|
||||||
releaseDate: "2026-05-04",
|
|
||||||
totalTracks: 1,
|
|
||||||
albumType: "album",
|
|
||||||
tracks: [track("album-track")]
|
|
||||||
};
|
|
||||||
},
|
|
||||||
getPlaylist: function(id) {
|
|
||||||
return {
|
|
||||||
id: id,
|
|
||||||
name: "Playlist " + id,
|
|
||||||
artists: "Owner",
|
|
||||||
coverUrl: "https://example.test/playlist.jpg",
|
|
||||||
totalTracks: 1,
|
|
||||||
tracks: [track("playlist-track")]
|
|
||||||
};
|
|
||||||
},
|
|
||||||
getArtist: function(id) {
|
|
||||||
return {
|
|
||||||
id: id,
|
|
||||||
name: "Artist",
|
|
||||||
imageUrl: "https://example.test/artist.jpg",
|
|
||||||
headerImage: "https://example.test/header.jpg",
|
|
||||||
listeners: 123,
|
|
||||||
albums: [{ id: "album-1", name: "Album", artists: "Artist", totalTracks: 1 }],
|
|
||||||
releases: [{ id: "release-1", name: "Release", artists: "Artist", totalTracks: 1, tracks: [track("release-track")] }],
|
|
||||||
topTracks: [track("top-track")]
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enrichTrack: function(input) {
|
|
||||||
var t = track(input.id || "enriched");
|
|
||||||
t.name = "Enriched";
|
|
||||||
return t;
|
|
||||||
},
|
|
||||||
checkAvailability: function(isrc, name, artist, ids) {
|
|
||||||
return { available: true, reason: "ok", trackId: "download-track", skipFallback: true };
|
|
||||||
},
|
|
||||||
getDownloadUrl: function(id, quality) {
|
|
||||||
return { url: "https://example.test/audio.flac", format: "flac", bitDepth: 24, sampleRate: 96000 };
|
|
||||||
},
|
|
||||||
download: function(id, quality, outputPath, onProgress) {
|
|
||||||
if (onProgress) onProgress(100);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
filePath: "EXISTS:" + outputPath,
|
|
||||||
alreadyExists: false,
|
|
||||||
bitDepth: 24,
|
|
||||||
sampleRate: 96000,
|
|
||||||
title: "Downloaded",
|
|
||||||
artist: "Artist",
|
|
||||||
album: "Album",
|
|
||||||
albumArtist: "Album Artist",
|
|
||||||
trackNumber: 1,
|
|
||||||
totalTracks: 10,
|
|
||||||
discNumber: 1,
|
|
||||||
totalDiscs: 1,
|
|
||||||
releaseDate: "2026-05-04",
|
|
||||||
coverUrl: "https://example.test/cover.jpg",
|
|
||||||
isrc: "USRC17607839",
|
|
||||||
genre: "Pop",
|
|
||||||
label: "Label",
|
|
||||||
copyright: "Copyright",
|
|
||||||
composer: "Composer",
|
|
||||||
lyricsLrc: "[00:00.00]Hello",
|
|
||||||
decryptionKey: "001122",
|
|
||||||
decryption: { strategy: "mp4_decryption_key", options: { kid: "1" } }
|
|
||||||
};
|
|
||||||
},
|
|
||||||
fetchLyrics: function(name, artist, album, duration) {
|
|
||||||
return { syncType: "LINE_SYNCED", provider: "coverage-ext", lines: [{ startTimeMs: 0, endTimeMs: 1000, words: "Hello" }] };
|
|
||||||
},
|
|
||||||
handleUrl: function(url) {
|
|
||||||
return { type: "track", name: "Handled", coverUrl: "https://example.test/cover.jpg", track: track("url-track"), tracks: [track("url-track")], album: this.getAlbum("url-album"), artist: this.getArtist("url-artist") };
|
|
||||||
},
|
|
||||||
matchTrack: function(req) {
|
|
||||||
return { matched: true, trackId: "download-track", confidence: 0.95, reason: "exact" };
|
|
||||||
},
|
|
||||||
postProcess: function(path, req) {
|
|
||||||
return { success: true, newFilePath: path, bitDepth: 24, sampleRate: 96000 };
|
|
||||||
},
|
|
||||||
postProcessV2: function(input, metadata, hookId) {
|
|
||||||
return { success: true, newFilePath: input.path || input.uri, newFileUri: input.uri || "", bitDepth: 24, sampleRate: 96000 };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
`
|
|
||||||
|
|
||||||
func mustReadFile(t *testing.T, path string) []byte {
|
|
||||||
t.Helper()
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read file: %v", err)
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildID3v23Tag(frames ...[]byte) []byte {
|
|
||||||
body := bytes.Join(frames, nil)
|
|
||||||
header := []byte{'I', 'D', '3', 3, 0, 0, 0, 0, 0, 0}
|
|
||||||
copy(header[6:10], syncsafeBytes(len(body)))
|
|
||||||
return append(header, body...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func id3TextFrame(id, value string) []byte {
|
|
||||||
return id3v23Frame(id, append([]byte{3}, []byte(value)...))
|
|
||||||
}
|
|
||||||
|
|
||||||
func id3CommentFrame(id, value string) []byte {
|
|
||||||
payload := append([]byte{3, 'e', 'n', 'g', 0}, []byte(value)...)
|
|
||||||
return id3v23Frame(id, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
func id3UserTextFrame(id, desc, value string) []byte {
|
|
||||||
payload := append([]byte{3}, []byte(desc)...)
|
|
||||||
payload = append(payload, 0)
|
|
||||||
payload = append(payload, []byte(value)...)
|
|
||||||
return id3v23Frame(id, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
func id3v23Frame(id string, payload []byte) []byte {
|
|
||||||
frame := make([]byte, 10+len(payload))
|
|
||||||
copy(frame[0:4], id)
|
|
||||||
binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload)))
|
|
||||||
copy(frame[10:], payload)
|
|
||||||
return frame
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildID3v22Tag(frames ...[]byte) []byte {
|
|
||||||
body := bytes.Join(frames, nil)
|
|
||||||
header := []byte{'I', 'D', '3', 2, 0, 0, 0, 0, 0, 0}
|
|
||||||
copy(header[6:10], syncsafeBytes(len(body)))
|
|
||||||
return append(header, body...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func id3v22TextFrame(id, value string) []byte {
|
|
||||||
return id3v22Frame(id, append([]byte{3}, []byte(value)...))
|
|
||||||
}
|
|
||||||
|
|
||||||
func id3v22CommentFrame(id, value string) []byte {
|
|
||||||
payload := append([]byte{3, 'e', 'n', 'g', 0}, []byte(value)...)
|
|
||||||
return id3v22Frame(id, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
func id3v22Frame(id string, payload []byte) []byte {
|
|
||||||
frame := make([]byte, 6+len(payload))
|
|
||||||
copy(frame[0:3], id)
|
|
||||||
size := len(payload)
|
|
||||||
frame[3] = byte(size >> 16)
|
|
||||||
frame[4] = byte(size >> 8)
|
|
||||||
frame[5] = byte(size)
|
|
||||||
copy(frame[6:], payload)
|
|
||||||
return frame
|
|
||||||
}
|
|
||||||
|
|
||||||
func syncsafeBytes(size int) []byte {
|
|
||||||
return []byte{
|
|
||||||
byte((size >> 21) & 0x7f),
|
|
||||||
byte((size >> 14) & 0x7f),
|
|
||||||
byte((size >> 7) & 0x7f),
|
|
||||||
byte(size & 0x7f),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildID3v1Tag(title, artist, album, year string, track, genre byte) []byte {
|
|
||||||
tag := make([]byte, 128)
|
|
||||||
copy(tag[0:3], "TAG")
|
|
||||||
copyPadded(tag[3:33], title)
|
|
||||||
copyPadded(tag[33:63], artist)
|
|
||||||
copyPadded(tag[63:93], album)
|
|
||||||
copyPadded(tag[93:97], year)
|
|
||||||
tag[125] = 0
|
|
||||||
tag[126] = track
|
|
||||||
tag[127] = genre
|
|
||||||
return tag
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyPadded(dst []byte, value string) {
|
|
||||||
for i := range dst {
|
|
||||||
dst[i] = ' '
|
|
||||||
}
|
|
||||||
copy(dst, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeExportCueFixture(t *testing.T, dir string) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
audioPath := filepath.Join(dir, "exports.wav")
|
|
||||||
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
|
|
||||||
t.Fatalf("write export audio: %v", err)
|
|
||||||
}
|
|
||||||
cuePath := filepath.Join(dir, "exports.cue")
|
|
||||||
cue := "PERFORMER \"Artist\"\nTITLE \"Album\"\nFILE \"exports.wav\" WAVE\n TRACK 01 AUDIO\n TITLE \"Song\"\n INDEX 01 00:00:00\n"
|
|
||||||
if err := os.WriteFile(cuePath, []byte(cue), 0600); err != nil {
|
|
||||||
t.Fatalf("write export cue: %v", err)
|
|
||||||
}
|
|
||||||
return cuePath, audioPath
|
|
||||||
}
|
|
||||||
|
|
||||||
func escapeJSONPath(path string) string {
|
|
||||||
data, _ := json.Marshal(path)
|
|
||||||
return strings.Trim(string(data), `"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fakeDeezerResponse(path, rawQuery string) string {
|
|
||||||
switch {
|
|
||||||
case path == "/2.0/search/track":
|
|
||||||
if strings.Contains(rawQuery, "MISSING") {
|
|
||||||
return `{"data":[]}`
|
|
||||||
}
|
|
||||||
return `{"data":[` + fakeDeezerTrackJSON(101, true) + `]}`
|
|
||||||
case path == "/2.0/search/artist":
|
|
||||||
return `{"data":[{"id":301,"name":"Artist","picture_xl":"artist-xl","nb_fan":123}]}`
|
|
||||||
case path == "/2.0/search/album":
|
|
||||||
return `{"data":[{"id":201,"title":"Album","cover_xl":"album-xl","nb_tracks":2,"release_date":"2026-05-04","record_type":"compile","artist":{"id":301,"name":"Artist"}}]}`
|
|
||||||
case path == "/2.0/search/playlist":
|
|
||||||
return `{"data":[{"id":401,"title":"Playlist","picture_xl":"playlist-xl","nb_tracks":2,"user":{"name":"Owner"}}]}`
|
|
||||||
case path == "/2.0/track/101", path == "/2.0/track/isrc:USRC17607839":
|
|
||||||
return fakeDeezerTrackJSON(101, true)
|
|
||||||
case path == "/2.0/track/102":
|
|
||||||
return fakeDeezerTrackJSON(102, true)
|
|
||||||
case path == "/2.0/track/isrc:MISSING":
|
|
||||||
return `{"id":0}`
|
|
||||||
case path == "/2.0/album/201":
|
|
||||||
return `{"id":201,"title":"Album","cover_xl":"album-xl","release_date":"2026-05-04","nb_tracks":2,"record_type":"compile","label":"Label","copyright":"Copyright","genres":{"data":[{"name":"Pop"},{"name":"Dance"}]},"artist":{"id":301,"name":"Album Artist"},"contributors":[{"name":"Contributor A"},{"name":"Contributor B"}],"tracks":{"data":[` + fakeDeezerTrackJSON(101, true) + `,` + fakeDeezerTrackJSON(102, false) + `]}}`
|
|
||||||
case path == "/2.0/artist/301":
|
|
||||||
return `{"id":301,"name":"Artist","picture_xl":"artist-xl","nb_fan":123,"nb_album":1}`
|
|
||||||
case path == "/2.0/artist/301/albums":
|
|
||||||
return `{"data":[{"id":201,"title":"Album","release_date":"2026-05-04","nb_tracks":0,"cover_xl":"album-xl","record_type":"compile"}]}`
|
|
||||||
case path == "/2.0/artist/301/related":
|
|
||||||
return `{"data":[{"id":302,"name":"Related","picture_xl":"related-xl","nb_fan":10}]}`
|
|
||||||
case path == "/2.0/playlist/401":
|
|
||||||
return `{"id":401,"title":"Playlist","picture_xl":"playlist-xl","nb_tracks":2,"creator":{"name":"Owner"},"tracks":{"data":[` + fakeDeezerTrackJSON(101, true) + `,` + fakeDeezerTrackJSON(102, false) + `]}}`
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fakeDeezerTrackJSON(id int, withISRC bool) string {
|
|
||||||
isrc := ""
|
|
||||||
if withISRC {
|
|
||||||
isrc = `,"isrc":"USRC17607839"`
|
|
||||||
if id == 102 {
|
|
||||||
isrc = `,"isrc":"USRC17607840"`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Sprintf(`{"id":%d,"title":"Track %d","duration":180,"track_position":%d,"disk_number":1%s,"link":"https://deezer.test/track/%d","release_date":"2026-05-04","artist":{"id":301,"name":"Artist"},"contributors":[{"name":"Contributor A"},{"name":"Contributor B"}],"album":{"id":201,"title":"Album","cover_xl":"album-xl","release_date":"2026-05-04","record_type":"album"}}`, id, id, id-100, isrc, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func createTestExtensionPackage(t *testing.T, path, name, version, js string, extraFiles map[string]string) {
|
|
||||||
t.Helper()
|
|
||||||
out, err := os.Create(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create extension package: %v", err)
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
zw := zip.NewWriter(out)
|
|
||||||
defer zw.Close()
|
|
||||||
|
|
||||||
manifest := fmt.Sprintf(`{
|
|
||||||
"name": %q,
|
|
||||||
"displayName": %q,
|
|
||||||
"version": %q,
|
|
||||||
"description": "Packaged test extension",
|
|
||||||
"type": ["metadata_provider", "download_provider", "lyrics_provider"],
|
|
||||||
"permissions": {"network": ["example.test"], "storage": true, "file": true},
|
|
||||||
"icon": "icon.png",
|
|
||||||
"settings": [{"key":"quality","type":"string","label":"Quality"}],
|
|
||||||
"qualityOptions": [{"id":"lossless","label":"Lossless","description":"Lossless"}],
|
|
||||||
"searchBehavior": {"enabled": true, "placeholder": "Search", "primary": true},
|
|
||||||
"urlHandler": {"enabled": true, "patterns": ["https://example.test/"]},
|
|
||||||
"trackMatching": {"customMatching": true},
|
|
||||||
"postProcessing": {"enabled": true, "hooks": [{"id":"hook","name":"Hook"}]},
|
|
||||||
"serviceHealth": [{"id":"main","url":"https://example.test/health"}],
|
|
||||||
"capabilities": {"homeFeed": true}
|
|
||||||
}`, name, name, version)
|
|
||||||
|
|
||||||
for fileName, content := range map[string]string{
|
|
||||||
"manifest.json": manifest,
|
|
||||||
"index.js": js,
|
|
||||||
"icon.png": "png",
|
|
||||||
} {
|
|
||||||
writer, err := zw.Create(fileName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("zip create %s: %v", fileName, err)
|
|
||||||
}
|
|
||||||
if _, err := writer.Write([]byte(content)); err != nil {
|
|
||||||
t.Fatalf("zip write %s: %v", fileName, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for fileName, content := range extraFiles {
|
|
||||||
writer, err := zw.Create(fileName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("zip create extra %s: %v", fileName, err)
|
|
||||||
}
|
|
||||||
if _, err := writer.Write([]byte(content)); err != nil {
|
|
||||||
t.Fatalf("zip write extra %s: %v", fileName, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,442 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CrossExtensionShareResult struct {
|
|
||||||
ExtensionID string `json:"extension_id"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
Found bool `json:"found"`
|
|
||||||
URL string `json:"url,omitempty"`
|
|
||||||
ItemName string `json:"item_name,omitempty"`
|
|
||||||
ItemArtists string `json:"item_artists,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var crossExtensionShareResultCache = struct {
|
|
||||||
sync.RWMutex
|
|
||||||
entries map[string]string
|
|
||||||
order []string
|
|
||||||
}{
|
|
||||||
entries: make(map[string]string),
|
|
||||||
}
|
|
||||||
|
|
||||||
const crossExtensionShareResultCacheLimit = 128
|
|
||||||
|
|
||||||
func FindCollectionAcrossExtensionsJSON(requestJSON string) (string, error) {
|
|
||||||
var req struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Artists string `json:"artists"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
SourceExtensionID string `json:"source_extension_id"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Name = strings.TrimSpace(req.Name)
|
|
||||||
req.Artists = strings.TrimSpace(req.Artists)
|
|
||||||
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
|
|
||||||
req.SourceExtensionID = strings.TrimSpace(req.SourceExtensionID)
|
|
||||||
if req.Name == "" {
|
|
||||||
return "[]", nil
|
|
||||||
}
|
|
||||||
if req.Type == "" {
|
|
||||||
req.Type = "album"
|
|
||||||
}
|
|
||||||
|
|
||||||
providers := getExtensionManager().GetMetadataProviders()
|
|
||||||
work := make([]*extensionProviderWrapper, 0, len(providers))
|
|
||||||
for _, provider := range providers {
|
|
||||||
if provider == nil || provider.extension == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if provider.extension.ID == req.SourceExtensionID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
work = append(work, provider)
|
|
||||||
}
|
|
||||||
cacheKey := crossExtensionShareCacheKey(req.Name, req.Artists, req.Type, req.SourceExtensionID, work)
|
|
||||||
if cached := getCrossExtensionShareCache(cacheKey); cached != "" {
|
|
||||||
return cached, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
query := req.Name
|
|
||||||
if req.Artists != "" {
|
|
||||||
query += " " + req.Artists
|
|
||||||
}
|
|
||||||
|
|
||||||
results := make([]CrossExtensionShareResult, len(work))
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for i, provider := range work {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(index int, p *extensionProviderWrapper) {
|
|
||||||
defer wg.Done()
|
|
||||||
results[index] = findCollectionForExtension(
|
|
||||||
p,
|
|
||||||
req.Type,
|
|
||||||
req.Name,
|
|
||||||
req.Artists,
|
|
||||||
query,
|
|
||||||
)
|
|
||||||
}(i, provider)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
data, err := json.Marshal(results)
|
|
||||||
if err != nil {
|
|
||||||
return "[]", err
|
|
||||||
}
|
|
||||||
response := string(data)
|
|
||||||
if crossExtensionShareResultsCacheable(results) {
|
|
||||||
setCrossExtensionShareCache(cacheKey, response)
|
|
||||||
}
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func crossExtensionShareCacheKey(name string, artists string, itemType string, sourceExtensionID string, providers []*extensionProviderWrapper) string {
|
|
||||||
providerKeys := make([]string, 0, len(providers))
|
|
||||||
for _, provider := range providers {
|
|
||||||
if provider == nil || provider.extension == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ext := provider.extension
|
|
||||||
displayName := ""
|
|
||||||
if ext.Manifest != nil {
|
|
||||||
displayName = ext.Manifest.DisplayName
|
|
||||||
}
|
|
||||||
providerKeys = append(providerKeys, strings.Join([]string{
|
|
||||||
strings.TrimSpace(ext.ID),
|
|
||||||
strings.TrimSpace(displayName),
|
|
||||||
strings.TrimSpace(ext.SourceDir),
|
|
||||||
}, "\x1f"))
|
|
||||||
}
|
|
||||||
sort.Strings(providerKeys)
|
|
||||||
|
|
||||||
return strings.Join([]string{
|
|
||||||
normalizeLooseTitle(itemType),
|
|
||||||
normalizeLooseTitle(name),
|
|
||||||
normalizeLooseArtistName(artists),
|
|
||||||
strings.TrimSpace(sourceExtensionID),
|
|
||||||
strings.Join(providerKeys, "\x1e"),
|
|
||||||
}, "\x1d")
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCrossExtensionShareCache(key string) string {
|
|
||||||
if key == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
crossExtensionShareResultCache.RLock()
|
|
||||||
defer crossExtensionShareResultCache.RUnlock()
|
|
||||||
return crossExtensionShareResultCache.entries[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
func setCrossExtensionShareCache(key string, value string) {
|
|
||||||
if key == "" || value == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
crossExtensionShareResultCache.Lock()
|
|
||||||
defer crossExtensionShareResultCache.Unlock()
|
|
||||||
|
|
||||||
if _, exists := crossExtensionShareResultCache.entries[key]; !exists {
|
|
||||||
crossExtensionShareResultCache.order = append(crossExtensionShareResultCache.order, key)
|
|
||||||
}
|
|
||||||
crossExtensionShareResultCache.entries[key] = value
|
|
||||||
|
|
||||||
for len(crossExtensionShareResultCache.order) > crossExtensionShareResultCacheLimit {
|
|
||||||
oldest := crossExtensionShareResultCache.order[0]
|
|
||||||
crossExtensionShareResultCache.order = crossExtensionShareResultCache.order[1:]
|
|
||||||
delete(crossExtensionShareResultCache.entries, oldest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func crossExtensionShareResultsCacheable(results []CrossExtensionShareResult) bool {
|
|
||||||
for _, result := range results {
|
|
||||||
if result.Found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
errText := strings.ToLower(strings.TrimSpace(result.Error))
|
|
||||||
if errText == "" ||
|
|
||||||
errText == "no results" ||
|
|
||||||
errText == "unsupported collection type" ||
|
|
||||||
strings.HasSuffix(errText, " not found") ||
|
|
||||||
strings.Contains(errText, "found without shareable link") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func findCollectionForExtension(
|
|
||||||
provider *extensionProviderWrapper,
|
|
||||||
itemType string,
|
|
||||||
name string,
|
|
||||||
artists string,
|
|
||||||
query string,
|
|
||||||
) CrossExtensionShareResult {
|
|
||||||
result := CrossExtensionShareResult{
|
|
||||||
ExtensionID: provider.extension.ID,
|
|
||||||
}
|
|
||||||
if provider.extension.Manifest != nil {
|
|
||||||
result.DisplayName = provider.extension.Manifest.DisplayName
|
|
||||||
}
|
|
||||||
if result.DisplayName == "" {
|
|
||||||
result.DisplayName = provider.extension.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
searchResult, err := searchCollectionCandidates(provider, itemType, query)
|
|
||||||
if err != nil {
|
|
||||||
result.Error = err.Error()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if searchResult == nil || len(searchResult.Tracks) == 0 {
|
|
||||||
result.Error = "no results"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
var best *ExtTrackMetadata
|
|
||||||
switch itemType {
|
|
||||||
case "artist":
|
|
||||||
best = bestArtistTrack(searchResult.Tracks, name)
|
|
||||||
case "album":
|
|
||||||
best = bestAlbumTrack(searchResult.Tracks, name, artists)
|
|
||||||
default:
|
|
||||||
result.Error = "unsupported collection type"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if best == nil {
|
|
||||||
result.Error = itemType + " not found"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
url := resolveCollectionShareURL(provider.extension, itemType, best)
|
|
||||||
if url == "" {
|
|
||||||
result.Error = itemType + " found without shareable link"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Found = true
|
|
||||||
result.URL = url
|
|
||||||
if itemType == "artist" {
|
|
||||||
result.ItemName = collectionArtistName(*best)
|
|
||||||
} else {
|
|
||||||
result.ItemName = collectionAlbumName(*best)
|
|
||||||
result.ItemArtists = best.Artists
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func searchCollectionCandidates(provider *extensionProviderWrapper, itemType string, query string) (*ExtSearchResult, error) {
|
|
||||||
filter := ""
|
|
||||||
switch itemType {
|
|
||||||
case "album":
|
|
||||||
filter = "albums"
|
|
||||||
case "artist":
|
|
||||||
filter = "artists"
|
|
||||||
}
|
|
||||||
|
|
||||||
if filter != "" {
|
|
||||||
tracks, err := provider.CustomSearch(query, map[string]interface{}{
|
|
||||||
"filter": filter,
|
|
||||||
"limit": 10,
|
|
||||||
})
|
|
||||||
if err == nil && len(tracks) > 0 {
|
|
||||||
return &ExtSearchResult{Tracks: tracks, Total: len(tracks)}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return provider.SearchTracks(query, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
func bestAlbumTrack(tracks []ExtTrackMetadata, albumName string, artists string) *ExtTrackMetadata {
|
|
||||||
targetAlbum := normalizeLooseTitle(albumName)
|
|
||||||
targetArtists := normalizeLooseArtistName(artists)
|
|
||||||
bestScore := 0
|
|
||||||
bestIndex := -1
|
|
||||||
|
|
||||||
for i := range tracks {
|
|
||||||
track := tracks[i]
|
|
||||||
album := normalizeLooseTitle(collectionAlbumName(track))
|
|
||||||
trackArtists := normalizeLooseArtistName(track.Artists + " " + track.AlbumArtist)
|
|
||||||
|
|
||||||
score := 0
|
|
||||||
if isCollectionItemType(track, "album") {
|
|
||||||
score += 25
|
|
||||||
}
|
|
||||||
if album == targetAlbum {
|
|
||||||
score += 100
|
|
||||||
} else if album != "" && targetAlbum != "" && (strings.Contains(album, targetAlbum) || strings.Contains(targetAlbum, album)) {
|
|
||||||
score += 50
|
|
||||||
}
|
|
||||||
if targetArtists != "" && (strings.Contains(trackArtists, targetArtists) || strings.Contains(targetArtists, trackArtists)) {
|
|
||||||
score += 30
|
|
||||||
}
|
|
||||||
if score > bestScore {
|
|
||||||
bestScore = score
|
|
||||||
bestIndex = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if bestIndex < 0 || bestScore < 50 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &tracks[bestIndex]
|
|
||||||
}
|
|
||||||
|
|
||||||
func bestArtistTrack(tracks []ExtTrackMetadata, artistName string) *ExtTrackMetadata {
|
|
||||||
targetArtist := normalizeLooseArtistName(artistName)
|
|
||||||
bestScore := 0
|
|
||||||
bestIndex := -1
|
|
||||||
|
|
||||||
for i := range tracks {
|
|
||||||
artist := normalizeLooseArtistName(collectionArtistName(tracks[i]))
|
|
||||||
score := 0
|
|
||||||
if isCollectionItemType(tracks[i], "artist") {
|
|
||||||
score += 25
|
|
||||||
}
|
|
||||||
if artist == targetArtist {
|
|
||||||
score += 100
|
|
||||||
} else if artist != "" && targetArtist != "" && (strings.Contains(artist, targetArtist) || strings.Contains(targetArtist, artist)) {
|
|
||||||
score += 60
|
|
||||||
}
|
|
||||||
if score > bestScore {
|
|
||||||
bestScore = score
|
|
||||||
bestIndex = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if bestIndex < 0 || bestScore < 60 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &tracks[bestIndex]
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *ExtTrackMetadata) string {
|
|
||||||
if track == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if itemType == "album" {
|
|
||||||
if isCollectionItemType(*track, "album") {
|
|
||||||
if url := normalizeShareURL(track.ExternalURL); url != "" {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if url := normalizeShareURL(track.AlbumURL); url != "" {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
if url := templateShareURL(ext, "album", firstNonEmptyString(track.AlbumID, collectionID(*track, "album"), track.AlbumURL)); url != "" {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if isCollectionItemType(*track, "artist") {
|
|
||||||
if url := normalizeShareURL(track.ExternalURL); url != "" {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if url := normalizeShareURL(track.ArtistURL); url != "" {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
if url := templateShareURL(ext, "artist", firstNonEmptyString(track.ArtistID, collectionID(*track, "artist"))); url != "" {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectionAlbumName(track ExtTrackMetadata) string {
|
|
||||||
if isCollectionItemType(track, "album") {
|
|
||||||
return track.Name
|
|
||||||
}
|
|
||||||
return track.AlbumName
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectionArtistName(track ExtTrackMetadata) string {
|
|
||||||
if isCollectionItemType(track, "artist") {
|
|
||||||
return track.Name
|
|
||||||
}
|
|
||||||
return track.Artists
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectionID(track ExtTrackMetadata, itemType string) string {
|
|
||||||
if isCollectionItemType(track, itemType) {
|
|
||||||
return track.ID
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func isCollectionItemType(track ExtTrackMetadata, itemType string) bool {
|
|
||||||
return strings.EqualFold(strings.TrimSpace(track.ItemType), itemType)
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeShareURL(value string) string {
|
|
||||||
trimmed := strings.TrimSpace(value)
|
|
||||||
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func urlFromExternalLinks(links map[string]string, preferredKey string) string {
|
|
||||||
for key, value := range links {
|
|
||||||
if strings.Contains(strings.ToLower(key), preferredKey) {
|
|
||||||
if url := normalizeShareURL(value); url != "" {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func templateShareURL(ext *loadedExtension, itemType string, id string) string {
|
|
||||||
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
id = stripProviderPrefix(strings.TrimSpace(id))
|
|
||||||
if id == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
rawTemplate, ok := templates[itemType].(string)
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
rawTemplate = strings.TrimSpace(rawTemplate)
|
|
||||||
if rawTemplate == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.ReplaceAll(rawTemplate, "{id}", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func stripProviderPrefix(id string) string {
|
|
||||||
if index := strings.Index(id, ":"); index > 0 && index < len(id)-1 {
|
|
||||||
return id[index+1:]
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstNonEmptyString(values ...string) string {
|
|
||||||
for _, value := range values {
|
|
||||||
trimmed := strings.TrimSpace(value)
|
|
||||||
if trimmed != "" {
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
|
|
||||||
ext := &loadedExtension{
|
|
||||||
Manifest: &ExtensionManifest{
|
|
||||||
Capabilities: map[string]interface{}{
|
|
||||||
"shareUrlTemplates": map[string]interface{}{
|
|
||||||
"album": "https://music.apple.com/us/album/{id}",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
tracks := []ExtTrackMetadata{
|
|
||||||
{
|
|
||||||
ID: "1440783617",
|
|
||||||
Name: "Nevermind",
|
|
||||||
Artists: "Nirvana",
|
|
||||||
ItemType: "album",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
best := bestAlbumTrack(tracks, "Nevermind", "Nirvana")
|
|
||||||
if best == nil {
|
|
||||||
t.Fatal("expected album collection item to match")
|
|
||||||
}
|
|
||||||
if url := resolveCollectionShareURL(ext, "album", best); url != "https://music.apple.com/us/album/1440783617" {
|
|
||||||
t.Fatalf("album share URL = %q", url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) {
|
|
||||||
ext := &loadedExtension{
|
|
||||||
Manifest: &ExtensionManifest{
|
|
||||||
Capabilities: map[string]interface{}{
|
|
||||||
"shareUrlTemplates": map[string]interface{}{
|
|
||||||
"artist": "https://music.youtube.com/browse/{id}",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
tracks := []ExtTrackMetadata{
|
|
||||||
{
|
|
||||||
ID: "UCrPe3hLA51968GwxHSZ1llw",
|
|
||||||
Name: "Nirvana",
|
|
||||||
ItemType: "artist",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
best := bestArtistTrack(tracks, "Nirvana")
|
|
||||||
if best == nil {
|
|
||||||
t.Fatal("expected artist collection item to match")
|
|
||||||
}
|
|
||||||
if url := resolveCollectionShareURL(ext, "artist", best); url != "https://music.youtube.com/browse/UCrPe3hLA51968GwxHSZ1llw" {
|
|
||||||
t.Fatalf("artist share URL = %q", url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCrossExtensionShareCacheKeyIsProviderOrderStable(t *testing.T) {
|
|
||||||
apple := &extensionProviderWrapper{
|
|
||||||
extension: &loadedExtension{
|
|
||||||
ID: "apple",
|
|
||||||
SourceDir: "/extensions/apple",
|
|
||||||
Manifest: &ExtensionManifest{DisplayName: "Apple Music"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
qobuz := &extensionProviderWrapper{
|
|
||||||
extension: &loadedExtension{
|
|
||||||
ID: "qobuz",
|
|
||||||
SourceDir: "/extensions/qobuz",
|
|
||||||
Manifest: &ExtensionManifest{DisplayName: "Qobuz"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
first := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{apple, qobuz})
|
|
||||||
second := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{qobuz, apple})
|
|
||||||
if first != second {
|
|
||||||
t.Fatalf("cache key should not depend on provider order:\n%s\n%s", first, second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCrossExtensionShareCacheableSkipsTransientErrors(t *testing.T) {
|
|
||||||
cacheable := []CrossExtensionShareResult{
|
|
||||||
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
|
|
||||||
{ExtensionID: "qobuz", Error: "album not found"},
|
|
||||||
{ExtensionID: "tidal", Error: "no results"},
|
|
||||||
}
|
|
||||||
if !crossExtensionShareResultsCacheable(cacheable) {
|
|
||||||
t.Fatal("expected found and deterministic not-found results to be cacheable")
|
|
||||||
}
|
|
||||||
|
|
||||||
transient := []CrossExtensionShareResult{
|
|
||||||
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
|
|
||||||
{ExtensionID: "qobuz", Error: "request failed: timeout"},
|
|
||||||
}
|
|
||||||
if crossExtensionShareResultsCacheable(transient) {
|
|
||||||
t.Fatal("expected transient extension errors to skip cache")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCueParserEndToEnd(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
audioPath := filepath.Join(dir, "album.wav")
|
|
||||||
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
|
|
||||||
t.Fatalf("write audio: %v", err)
|
|
||||||
}
|
|
||||||
cuePath := filepath.Join(dir, "album.cue")
|
|
||||||
cue := "\ufeffREM GENRE \"Pop\"\n" +
|
|
||||||
"REM DATE 2026\n" +
|
|
||||||
"REM COMMENT \"comment\"\n" +
|
|
||||||
"REM COMPOSER \"Album Composer\"\n" +
|
|
||||||
"PERFORMER \"Album Artist\"\n" +
|
|
||||||
"TITLE \"Album Title\"\n" +
|
|
||||||
"FILE \"album.wav\" WAVE\n" +
|
|
||||||
" TRACK 01 AUDIO\n" +
|
|
||||||
" TITLE \"First\"\n" +
|
|
||||||
" PERFORMER \"Track Artist\"\n" +
|
|
||||||
" ISRC USRC17607839\n" +
|
|
||||||
" INDEX 01 00:00:00\n" +
|
|
||||||
" TRACK 02 AUDIO\n" +
|
|
||||||
" TITLE \"Second\"\n" +
|
|
||||||
" SONGWRITER \"Track Composer\"\n" +
|
|
||||||
" INDEX 00 03:00:00\n" +
|
|
||||||
" INDEX 01 03:05:00\n"
|
|
||||||
if err := os.WriteFile(cuePath, []byte(cue), 0600); err != nil {
|
|
||||||
t.Fatalf("write cue: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sheet, err := ParseCueFile(cuePath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseCueFile: %v", err)
|
|
||||||
}
|
|
||||||
if sheet.Performer != "Album Artist" || sheet.Title != "Album Title" || len(sheet.Tracks) != 2 {
|
|
||||||
t.Fatalf("sheet = %#v", sheet)
|
|
||||||
}
|
|
||||||
if got := parseCueTimestamp("01:02:37"); got <= 62 || got >= 63 {
|
|
||||||
t.Fatalf("timestamp = %f", got)
|
|
||||||
}
|
|
||||||
if got := formatCueTimestamp(3723.5); got != "01:02:03.500" {
|
|
||||||
t.Fatalf("format timestamp = %q", got)
|
|
||||||
}
|
|
||||||
if got := unquoteCue(" \"quoted\" "); got != "quoted" {
|
|
||||||
t.Fatalf("unquote = %q", got)
|
|
||||||
}
|
|
||||||
fileName, fileType := parseCueFileLine("unquoted album.flac FLAC")
|
|
||||||
if fileName != "unquoted album.flac" || fileType != "FLAC" {
|
|
||||||
t.Fatalf("file line = %q/%q", fileName, fileType)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resolved := ResolveCueAudioPath(cuePath, "album.flac"); resolved != audioPath {
|
|
||||||
t.Fatalf("resolved = %q want %q", resolved, audioPath)
|
|
||||||
}
|
|
||||||
info, err := BuildCueSplitInfo(cuePath, sheet, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("BuildCueSplitInfo: %v", err)
|
|
||||||
}
|
|
||||||
if info.Tracks[0].EndSec != 180 || info.Tracks[1].Composer != "Track Composer" {
|
|
||||||
t.Fatalf("split info = %#v", info.Tracks)
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonText, err := ParseCueFileJSON(cuePath, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseCueFileJSON: %v", err)
|
|
||||||
}
|
|
||||||
var decoded CueSplitInfo
|
|
||||||
if err := json.Unmarshal([]byte(jsonText), &decoded); err != nil {
|
|
||||||
t.Fatalf("decode cue json: %v", err)
|
|
||||||
}
|
|
||||||
if decoded.AudioPath != audioPath {
|
|
||||||
t.Fatalf("decoded audio path = %q", decoded.AudioPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
results, err := ScanCueFileForLibraryExt(cuePath, "", "virtual/album.cue", 1234, "scan-time")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ScanCueFileForLibraryExt: %v", err)
|
|
||||||
}
|
|
||||||
if len(results) != 2 || results[0].TrackName != "First" || results[0].Duration != 180 {
|
|
||||||
t.Fatalf("scan results = %#v", results)
|
|
||||||
}
|
|
||||||
if results[0].FilePath != "virtual/album.cue#track01" || results[0].Format != "cue+wav" {
|
|
||||||
t.Fatalf("scan path/format = %q/%q", results[0].FilePath, results[0].Format)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := ParseCueFile(filepath.Join(dir, "missing.cue")); err == nil {
|
|
||||||
t.Fatal("expected missing cue error")
|
|
||||||
}
|
|
||||||
emptyCue := filepath.Join(dir, "empty.cue")
|
|
||||||
if err := os.WriteFile(emptyCue, []byte("TITLE \"No tracks\""), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := ParseCueFile(emptyCue); err == nil {
|
|
||||||
t.Fatal("expected no tracks error")
|
|
||||||
}
|
|
||||||
missingDir := t.TempDir()
|
|
||||||
missingCuePath := filepath.Join(missingDir, "missing.cue")
|
|
||||||
if err := os.WriteFile(missingCuePath, []byte(cue), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := BuildCueSplitInfo(missingCuePath, &CueSheet{FileName: "missing.wav"}, ""); err == nil {
|
|
||||||
t.Fatal("expected missing audio error")
|
|
||||||
}
|
|
||||||
if _, err := resolveCueAudioPathForLibrary(cuePath, nil, ""); err == nil {
|
|
||||||
t.Fatal("expected nil sheet error")
|
|
||||||
}
|
|
||||||
if _, err := scanCueSheetForLibrary(cuePath, nil, audioPath, "", 0, "", ""); err == nil {
|
|
||||||
t.Fatal("expected nil scan sheet error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDuplicateIndexAndParallelExistence(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
filePath := filepath.Join(dir, "song.flac")
|
|
||||||
if err := os.WriteFile(filePath, []byte("audio"), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
if got, err := idx.Lookup("usrc17607839"); err != nil || got != filePath {
|
|
||||||
t.Fatalf("Lookup = %q/%v", got, err)
|
|
||||||
}
|
|
||||||
idx.remove("usrc17607839")
|
|
||||||
if _, ok := idx.lookup("usrc17607839"); ok {
|
|
||||||
t.Fatal("expected removed ISRC")
|
|
||||||
}
|
|
||||||
|
|
||||||
isrcIndexCacheMu.Lock()
|
|
||||||
isrcIndexCache[dir] = idx
|
|
||||||
isrcIndexCacheMu.Unlock()
|
|
||||||
defer InvalidateISRCCache(dir)
|
|
||||||
|
|
||||||
AddToISRCIndex(dir, "USRC17607839", filePath)
|
|
||||||
if found, err := CheckISRCExists(dir, "USRC17607839"); err != nil || found != filePath {
|
|
||||||
t.Fatalf("CheckISRCExists = %q/%v", found, err)
|
|
||||||
}
|
|
||||||
if !CheckFileExists(filePath) || CheckFileExists(dir) || CheckFileExists(filepath.Join(dir, "missing.flac")) {
|
|
||||||
t.Fatal("unexpected file existence result")
|
|
||||||
}
|
|
||||||
|
|
||||||
tracksJSON := `[{"isrc":"USRC17607839","track_name":"Song","artist_name":"Artist"},{"isrc":"MISSING","track_name":"Other","artist_name":"Artist"}]`
|
|
||||||
resultJSON, err := CheckFilesExistParallel(dir, tracksJSON)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CheckFilesExistParallel: %v", err)
|
|
||||||
}
|
|
||||||
var results []FileExistenceResult
|
|
||||||
if err := json.Unmarshal([]byte(resultJSON), &results); err != nil {
|
|
||||||
t.Fatalf("decode results: %v", err)
|
|
||||||
}
|
|
||||||
if !results[0].Exists || results[0].FilePath != filePath || results[1].Exists {
|
|
||||||
t.Fatalf("results = %#v", results)
|
|
||||||
}
|
|
||||||
if _, err := CheckFilesExistParallel(dir, `not-json`); err == nil {
|
|
||||||
t.Fatal("expected invalid json error")
|
|
||||||
}
|
|
||||||
if err := PreBuildISRCIndex(""); err == nil {
|
|
||||||
t.Fatal("expected empty dir error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -264,7 +264,7 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
|
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
|
||||||
commonExts := []string{".flac", ".wav", ".aiff", ".aif", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
||||||
for _, ext := range commonExts {
|
for _, ext := range commonExts {
|
||||||
candidate = filepath.Join(cueDir, baseName+ext)
|
candidate = filepath.Join(cueDir, baseName+ext)
|
||||||
if _, err := os.Stat(candidate); err == nil {
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
@@ -513,11 +513,6 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
|||||||
album = "Unknown Album"
|
album = "Unknown Album"
|
||||||
}
|
}
|
||||||
|
|
||||||
composer := track.Composer
|
|
||||||
if composer == "" {
|
|
||||||
composer = sheet.Composer
|
|
||||||
}
|
|
||||||
|
|
||||||
var duration int
|
var duration int
|
||||||
if i+1 < len(sheet.Tracks) {
|
if i+1 < len(sheet.Tracks) {
|
||||||
nextStart := sheet.Tracks[i+1].StartTime
|
nextStart := sheet.Tracks[i+1].StartTime
|
||||||
@@ -544,15 +539,12 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
|||||||
ScannedAt: scanTime,
|
ScannedAt: scanTime,
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
TrackNumber: track.Number,
|
TrackNumber: track.Number,
|
||||||
TotalTracks: len(sheet.Tracks),
|
|
||||||
DiscNumber: 1,
|
DiscNumber: 1,
|
||||||
TotalDiscs: 1,
|
|
||||||
Duration: duration,
|
Duration: duration,
|
||||||
ReleaseDate: sheet.Date,
|
ReleaseDate: sheet.Date,
|
||||||
BitDepth: bitDepth,
|
BitDepth: bitDepth,
|
||||||
SampleRate: sampleRate,
|
SampleRate: sampleRate,
|
||||||
Genre: sheet.Genre,
|
Genre: sheet.Genre,
|
||||||
Composer: composer,
|
|
||||||
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
|
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -631,12 +630,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
}
|
}
|
||||||
|
|
||||||
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
||||||
totalDiscs := 0
|
|
||||||
for _, track := range allTracks {
|
|
||||||
if track.DiskNumber > totalDiscs {
|
|
||||||
totalDiscs = track.DiskNumber
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
||||||
albumType := album.RecordType
|
albumType := album.RecordType
|
||||||
@@ -665,7 +658,6 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
TrackNumber: trackNum,
|
TrackNumber: trackNum,
|
||||||
TotalTracks: album.NbTracks,
|
TotalTracks: album.NbTracks,
|
||||||
DiscNumber: track.DiskNumber,
|
DiscNumber: track.DiskNumber,
|
||||||
TotalDiscs: totalDiscs,
|
|
||||||
ExternalURL: track.Link,
|
ExternalURL: track.Link,
|
||||||
ISRC: isrc,
|
ISRC: isrc,
|
||||||
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
||||||
@@ -784,6 +776,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
// not include this field. Albums whose track count is already known (non-zero)
|
// not include this field. Albums whose track count is already known (non-zero)
|
||||||
// are skipped.
|
// are skipped.
|
||||||
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
|
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
|
||||||
|
// Find albums that need track counts
|
||||||
type indexedID struct {
|
type indexedID struct {
|
||||||
idx int
|
idx int
|
||||||
albumID string
|
albumID string
|
||||||
@@ -1267,7 +1260,16 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastErr = err
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1277,26 +1279,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
|
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
type deezerAPIError struct {
|
|
||||||
StatusCode int
|
|
||||||
Body string
|
|
||||||
}
|
|
||||||
|
|
||||||
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 interface{}) error {
|
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1317,7 +1299,7 @@ func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst inter
|
|||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return &deezerAPIError{StatusCode: resp.StatusCode, Body: string(body)}
|
return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.Unmarshal(body, dst)
|
return json.Unmarshal(body, dst)
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const deezerMusicDLURL = "https://api.zarz.moe/v1/dzr"
|
||||||
|
|
||||||
|
type DeezerDownloadResult struct {
|
||||||
|
FilePath string
|
||||||
|
BitDepth int
|
||||||
|
SampleRate int
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
ReleaseDate string
|
||||||
|
TrackNumber int
|
||||||
|
DiscNumber int
|
||||||
|
ISRC string
|
||||||
|
LyricsLRC string
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLikelySpotifyTrackID(value string) bool {
|
||||||
|
if len(value) != 22 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range value {
|
||||||
|
switch {
|
||||||
|
case r >= 'A' && r <= 'Z':
|
||||||
|
case r >= 'a' && r <= 'z':
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
||||||
|
deezerID := strings.TrimSpace(req.DeezerID)
|
||||||
|
if deezerID == "" {
|
||||||
|
if prefixed, found := strings.CutPrefix(strings.TrimSpace(req.SpotifyID), "deezer:"); found {
|
||||||
|
deezerID = strings.TrimSpace(prefixed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if deezerID != "" {
|
||||||
|
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
|
||||||
|
if err := verifyDeezerTrack(req, deezerID, false); err != nil {
|
||||||
|
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
|
||||||
|
// Don't reject direct IDs from request payload — they're presumably correct.
|
||||||
|
}
|
||||||
|
return trackURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
spotifyID := strings.TrimSpace(req.SpotifyID)
|
||||||
|
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
|
||||||
|
songlink := NewSongLinkClient()
|
||||||
|
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
|
||||||
|
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
||||||
|
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
|
||||||
|
if resolvedID != "" {
|
||||||
|
if verifyErr := verifyDeezerTrack(req, resolvedID, true); verifyErr != nil {
|
||||||
|
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||||
|
// Fall through to ISRC search instead of using wrong track.
|
||||||
|
} else {
|
||||||
|
return availability.DeezerURL, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return availability.DeezerURL, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isrc := strings.TrimSpace(req.ISRC)
|
||||||
|
if isrc != "" {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||||
|
defer cancel()
|
||||||
|
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
|
||||||
|
if err == nil && track != nil {
|
||||||
|
resolvedID := songLinkExtractDeezerTrackID(track)
|
||||||
|
if resolvedID != "" {
|
||||||
|
if verifyErr := verifyDeezerTrack(req, resolvedID, false); verifyErr != nil {
|
||||||
|
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||||
|
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("could not resolve Deezer track URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||||
|
defer cancel()
|
||||||
|
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil // Can't verify — don't block the download.
|
||||||
|
}
|
||||||
|
resolved := resolvedTrackInfo{
|
||||||
|
Title: trackResp.Track.Name,
|
||||||
|
ArtistName: trackResp.Track.Artists,
|
||||||
|
ISRC: trackResp.Track.ISRC,
|
||||||
|
Duration: trackResp.Track.DurationMS / 1000,
|
||||||
|
SkipNameVerification: skipNameVerification,
|
||||||
|
}
|
||||||
|
if !trackMatchesRequest(req, resolved, "Deezer") {
|
||||||
|
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
|
||||||
|
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
|
||||||
|
}
|
||||||
|
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type deezerMusicDLRequest struct {
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, error) {
|
||||||
|
payload := deezerMusicDLRequest{
|
||||||
|
Platform: "deezer",
|
||||||
|
URL: deezerTrackURL,
|
||||||
|
}
|
||||||
|
jsonData, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to encode MusicDL request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, deezerMusicDLURL, bytes.NewReader(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("MusicDL request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("MusicDL returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var raw map[string]any
|
||||||
|
if err := json.Unmarshal(body, &raw); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid MusicDL JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
|
||||||
|
return "", fmt.Errorf("MusicDL error: %s", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range []string{"download_url", "url", "link"} {
|
||||||
|
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||||
|
return strings.TrimSpace(urlVal), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data, ok := raw["data"].(map[string]any); ok {
|
||||||
|
for _, key := range []string{"download_url", "url", "link"} {
|
||||||
|
if urlVal, ok := data[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||||
|
return strings.TrimSpace(urlVal), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no download URL found in MusicDL response")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, outputFD int, itemID string) error {
|
||||||
|
GoLog("[Deezer] Resolving download URL via MusicDL for: %s\n", deezerTrackURL)
|
||||||
|
|
||||||
|
downloadURL, err := c.GetMusicDLDownloadURL(deezerTrackURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
GoLog("[Deezer] MusicDL returned download URL, starting download...\n")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if itemID != "" {
|
||||||
|
StartItemProgress(itemID)
|
||||||
|
defer CompleteItemProgress(itemID)
|
||||||
|
ctx = initDownloadCancel(itemID)
|
||||||
|
defer clearDownloadCancel(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create download request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
|
|
||||||
|
resp, err := GetDownloadClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
return fmt.Errorf("download request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedSize := resp.ContentLength
|
||||||
|
if expectedSize > 0 && itemID != "" {
|
||||||
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := openOutputForWrite(outputPath, outputFD)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
var written int64
|
||||||
|
if itemID != "" {
|
||||||
|
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||||
|
written, err = io.Copy(pw, resp.Body)
|
||||||
|
} else {
|
||||||
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
flushErr := bufWriter.Flush()
|
||||||
|
closeErr := out.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
|
}
|
||||||
|
if flushErr != nil {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
return fmt.Errorf("failed to flush output: %w", flushErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
return fmt.Errorf("failed to close output: %w", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if expectedSize > 0 && written != expectedSize {
|
||||||
|
cleanupOutputOnError(outputPath, outputFD)
|
||||||
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] Downloaded via MusicDL: %.2f MB\n", float64(written)/(1024*1024))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
||||||
|
deezerClient := GetDeezerClient()
|
||||||
|
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||||
|
|
||||||
|
if !isSafOutput {
|
||||||
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
|
return DeezerDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
|
"title": req.TrackName,
|
||||||
|
"artist": req.ArtistName,
|
||||||
|
"album": req.AlbumName,
|
||||||
|
"track": req.TrackNumber,
|
||||||
|
"year": extractYear(req.ReleaseDate),
|
||||||
|
"date": req.ReleaseDate,
|
||||||
|
"disc": req.DiscNumber,
|
||||||
|
})
|
||||||
|
|
||||||
|
var outputPath string
|
||||||
|
if isSafOutput {
|
||||||
|
outputPath = strings.TrimSpace(req.OutputPath)
|
||||||
|
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||||
|
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filename = sanitizeFilename(filename) + ".flac"
|
||||||
|
outputPath = filepath.Join(req.OutputDir, filename)
|
||||||
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
|
return DeezerDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var parallelResult *ParallelDownloadResult
|
||||||
|
parallelDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(parallelDone)
|
||||||
|
coverURL := req.CoverURL
|
||||||
|
embedLyrics := req.EmbedLyrics
|
||||||
|
if !req.EmbedMetadata {
|
||||||
|
coverURL = ""
|
||||||
|
embedLyrics = false
|
||||||
|
}
|
||||||
|
parallelResult = FetchCoverAndLyricsParallel(
|
||||||
|
coverURL,
|
||||||
|
req.EmbedMaxQualityCover,
|
||||||
|
req.SpotifyID,
|
||||||
|
req.TrackName,
|
||||||
|
req.ArtistName,
|
||||||
|
embedLyrics,
|
||||||
|
int64(req.DurationMS),
|
||||||
|
)
|
||||||
|
}()
|
||||||
|
|
||||||
|
deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req)
|
||||||
|
if deezerURLErr != nil {
|
||||||
|
return DeezerDownloadResult{}, fmt.Errorf(
|
||||||
|
"deezer download failed: could not resolve Deezer URL: %w",
|
||||||
|
deezerURLErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
|
||||||
|
downloadErr := deezerClient.DownloadFromMusicDL(
|
||||||
|
deezerTrackURL,
|
||||||
|
outputPath,
|
||||||
|
req.OutputFD,
|
||||||
|
req.ItemID,
|
||||||
|
)
|
||||||
|
if downloadErr != nil {
|
||||||
|
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
||||||
|
return DeezerDownloadResult{}, ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
return DeezerDownloadResult{}, fmt.Errorf(
|
||||||
|
"deezer download failed via MusicDL: %w",
|
||||||
|
downloadErr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
<-parallelDone
|
||||||
|
|
||||||
|
if req.ItemID != "" {
|
||||||
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
|
SetItemFinalizing(req.ItemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := Metadata{
|
||||||
|
Title: req.TrackName,
|
||||||
|
Artist: req.ArtistName,
|
||||||
|
Album: req.AlbumName,
|
||||||
|
AlbumArtist: req.AlbumArtist,
|
||||||
|
ArtistTagMode: req.ArtistTagMode,
|
||||||
|
Date: req.ReleaseDate,
|
||||||
|
TrackNumber: req.TrackNumber,
|
||||||
|
TotalTracks: req.TotalTracks,
|
||||||
|
DiscNumber: req.DiscNumber,
|
||||||
|
ISRC: req.ISRC,
|
||||||
|
Genre: req.Genre,
|
||||||
|
Label: req.Label,
|
||||||
|
Copyright: req.Copyright,
|
||||||
|
}
|
||||||
|
|
||||||
|
var coverData []byte
|
||||||
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||||
|
coverData = parallelResult.CoverData
|
||||||
|
}
|
||||||
|
|
||||||
|
if isSafOutput || !req.EmbedMetadata {
|
||||||
|
if !req.EmbedMetadata {
|
||||||
|
GoLog("[Deezer] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||||
|
GoLog("[Deezer] Warning: failed to embed metadata: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
|
lyricsMode := req.LyricsMode
|
||||||
|
if lyricsMode == "" {
|
||||||
|
lyricsMode = "embed"
|
||||||
|
}
|
||||||
|
|
||||||
|
if lyricsMode == "external" || lyricsMode == "both" {
|
||||||
|
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||||
|
GoLog("[Deezer] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] LRC file saved: %s\n", lrcPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||||
|
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||||
|
GoLog("[Deezer] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isSafOutput {
|
||||||
|
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
bitDepth, sampleRate := 0, 0
|
||||||
|
if quality, qErr := GetAudioQuality(outputPath); qErr == nil {
|
||||||
|
bitDepth = quality.BitDepth
|
||||||
|
sampleRate = quality.SampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
lyricsLRC := ""
|
||||||
|
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||||
|
lyricsLRC = parallelResult.LyricsLRC
|
||||||
|
}
|
||||||
|
|
||||||
|
return DeezerDownloadResult{
|
||||||
|
FilePath: outputPath,
|
||||||
|
BitDepth: bitDepth,
|
||||||
|
SampleRate: sampleRate,
|
||||||
|
Title: req.TrackName,
|
||||||
|
Artist: req.ArtistName,
|
||||||
|
Album: req.AlbumName,
|
||||||
|
ReleaseDate: req.ReleaseDate,
|
||||||
|
TrackNumber: req.TrackNumber,
|
||||||
|
DiscNumber: req.DiscNumber,
|
||||||
|
ISRC: req.ISRC,
|
||||||
|
LyricsLRC: lyricsLRC,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDeezerClientWithFakeHTTP(t *testing.T) {
|
|
||||||
client := &DeezerClient{
|
|
||||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
|
|
||||||
status := http.StatusOK
|
|
||||||
if body == "" {
|
|
||||||
status = http.StatusNotFound
|
|
||||||
body = `{"error":"missing"}`
|
|
||||||
}
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: status,
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
|
||||||
Request: req,
|
|
||||||
}, nil
|
|
||||||
})},
|
|
||||||
searchCache: map[string]*cacheEntry{},
|
|
||||||
albumCache: map[string]*cacheEntry{},
|
|
||||||
artistCache: map[string]*cacheEntry{},
|
|
||||||
isrcCache: map[string]string{},
|
|
||||||
cacheCleanupInterval: time.Millisecond,
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
search, err := client.SearchAll(ctx, "artist song", 2, 2, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SearchAll: %v", err)
|
|
||||||
}
|
|
||||||
if len(search.Tracks) != 1 || len(search.Artists) != 1 || len(search.Albums) != 1 || len(search.Playlists) != 1 {
|
|
||||||
t.Fatalf("search = %#v", search)
|
|
||||||
}
|
|
||||||
cached, err := client.SearchAll(ctx, "artist song", 2, 2, "")
|
|
||||||
if err != nil || cached != search {
|
|
||||||
t.Fatalf("cached SearchAll = %#v/%v", cached, err)
|
|
||||||
}
|
|
||||||
if filtered, err := client.SearchAll(ctx, "artist song", 1, 1, "track"); err != nil || len(filtered.Tracks) != 1 || len(filtered.Artists) != 0 {
|
|
||||||
t.Fatalf("filtered search = %#v/%v", filtered, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
track, err := client.GetTrack(ctx, "101")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetTrack: %v", err)
|
|
||||||
}
|
|
||||||
if track.Track.SpotifyID != "deezer:101" || track.Track.Artists != "Contributor A, Contributor B" {
|
|
||||||
t.Fatalf("track = %#v", track)
|
|
||||||
}
|
|
||||||
|
|
||||||
album, err := client.GetAlbum(ctx, "201")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAlbum: %v", err)
|
|
||||||
}
|
|
||||||
if album.AlbumInfo.Name != "Album" || len(album.TrackList) != 2 || album.TrackList[1].ISRC == "" {
|
|
||||||
t.Fatalf("album = %#v", album)
|
|
||||||
}
|
|
||||||
if cachedAlbum, err := client.GetAlbum(ctx, "201"); err != nil || cachedAlbum != album {
|
|
||||||
t.Fatalf("cached album = %#v/%v", cachedAlbum, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
artist, err := client.GetArtist(ctx, "301")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetArtist: %v", err)
|
|
||||||
}
|
|
||||||
if artist.ArtistInfo.Name != "Artist" || len(artist.Albums) != 1 || artist.Albums[0].TotalTracks == 0 {
|
|
||||||
t.Fatalf("artist = %#v", artist)
|
|
||||||
}
|
|
||||||
if cachedArtist, err := client.GetArtist(ctx, "301"); err != nil || cachedArtist != artist {
|
|
||||||
t.Fatalf("cached artist = %#v/%v", cachedArtist, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
related, err := client.GetRelatedArtists(ctx, "deezer:301", 3)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetRelatedArtists: %v", err)
|
|
||||||
}
|
|
||||||
if len(related) != 1 || related[0].ID != "deezer:302" {
|
|
||||||
t.Fatalf("related = %#v", related)
|
|
||||||
}
|
|
||||||
if _, err := client.GetRelatedArtists(ctx, "", 0); err == nil {
|
|
||||||
t.Fatal("expected invalid related artist ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
playlist, err := client.GetPlaylist(ctx, "401")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetPlaylist: %v", err)
|
|
||||||
}
|
|
||||||
if playlist.PlaylistInfo.Tracks.Total != 2 || len(playlist.TrackList) != 2 {
|
|
||||||
t.Fatalf("playlist = %#v", playlist)
|
|
||||||
}
|
|
||||||
|
|
||||||
byISRC, err := client.SearchByISRC(ctx, "USRC17607839")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SearchByISRC: %v", err)
|
|
||||||
}
|
|
||||||
if byISRC.SpotifyID != "deezer:101" {
|
|
||||||
t.Fatalf("by ISRC = %#v", byISRC)
|
|
||||||
}
|
|
||||||
if _, err := client.SearchByISRC(ctx, "MISSING"); err == nil {
|
|
||||||
t.Fatal("expected missing ISRC error")
|
|
||||||
}
|
|
||||||
|
|
||||||
isrc, err := client.GetTrackISRC(ctx, "102")
|
|
||||||
if err != nil || isrc != "USRC17607840" {
|
|
||||||
t.Fatalf("GetTrackISRC = %q/%v", isrc, err)
|
|
||||||
}
|
|
||||||
albumID, err := client.GetTrackAlbumID(ctx, "101")
|
|
||||||
if err != nil || albumID != "201" {
|
|
||||||
t.Fatalf("GetTrackAlbumID = %q/%v", albumID, err)
|
|
||||||
}
|
|
||||||
extended, err := client.GetAlbumExtendedMetadata(ctx, "201")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAlbumExtendedMetadata: %v", err)
|
|
||||||
}
|
|
||||||
if extended.Genre != "Pop, Dance" || extended.Label != "Label" {
|
|
||||||
t.Fatalf("extended = %#v", extended)
|
|
||||||
}
|
|
||||||
if byTrack, err := client.GetExtendedMetadataByTrackID(ctx, "101"); err != nil || byTrack.Label != "Label" {
|
|
||||||
t.Fatalf("metadata by track = %#v/%v", byTrack, err)
|
|
||||||
}
|
|
||||||
if byISRCMeta, err := client.GetExtendedMetadataByISRC(ctx, "USRC17607839"); err != nil || byISRCMeta.Label != "Label" {
|
|
||||||
t.Fatalf("metadata by isrc = %#v/%v", byISRCMeta, err)
|
|
||||||
}
|
|
||||||
if _, err := client.GetExtendedMetadataByISRC(ctx, ""); err == nil {
|
|
||||||
t.Fatal("expected empty ISRC metadata error")
|
|
||||||
}
|
|
||||||
|
|
||||||
if typ, id, err := parseDeezerURL("https://www.deezer.com/us/track/101"); err != nil || typ != "track" || id != "101" {
|
|
||||||
t.Fatalf("parseDeezerURL = %q/%q/%v", typ, id, err)
|
|
||||||
}
|
|
||||||
if _, _, err := parseDeezerURL("https://example.com/track/101"); err == nil {
|
|
||||||
t.Fatal("expected non-Deezer URL error")
|
|
||||||
}
|
|
||||||
|
|
||||||
client.cacheMu.Lock()
|
|
||||||
client.searchCache["expired"] = &cacheEntry{expiresAt: time.Now().Add(-time.Hour)}
|
|
||||||
client.searchCache["keep1"] = &cacheEntry{expiresAt: time.Now().Add(time.Hour)}
|
|
||||||
client.searchCache["keep2"] = &cacheEntry{expiresAt: time.Now().Add(2 * time.Hour)}
|
|
||||||
client.pruneExpiredCacheEntriesLocked(client.searchCache, time.Now())
|
|
||||||
client.trimCacheEntriesLocked(client.searchCache, 1)
|
|
||||||
client.isrcCache["1"] = "A"
|
|
||||||
client.isrcCache["2"] = "B"
|
|
||||||
client.trimStringCacheEntriesLocked(client.isrcCache, 1)
|
|
||||||
client.cacheMu.Unlock()
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestExtensionPackageExportWrappers(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
extensionsDir := filepath.Join(dir, "extensions")
|
|
||||||
dataDir := filepath.Join(dir, "data")
|
|
||||||
if err := InitExtensionSystem(extensionsDir, dataDir); err != nil {
|
|
||||||
t.Fatalf("InitExtensionSystem: %v", err)
|
|
||||||
}
|
|
||||||
CleanupExtensions()
|
|
||||||
defer CleanupExtensions()
|
|
||||||
|
|
||||||
js := `
|
|
||||||
registerExtension({
|
|
||||||
initialize: function(settings) { this.settings = settings || {}; },
|
|
||||||
cleanup: function() {},
|
|
||||||
doAction: function() { return { message: "wrapped", setting_updates: { quality: "lossless" } }; },
|
|
||||||
searchTracks: function() { return { tracks: [], total: 0 }; },
|
|
||||||
fetchLyrics: function() { return { syncType: "UNSYNCED", lines: [{ words: "hello" }] }; },
|
|
||||||
getDownloadUrl: function() { return { url: "https://example.test/a.flac" }; }
|
|
||||||
});
|
|
||||||
`
|
|
||||||
pkgV1 := filepath.Join(dir, "wrapper-ext-v1.spotiflac-ext")
|
|
||||||
pkgV2 := filepath.Join(dir, "wrapper-ext-v2.spotiflac-ext")
|
|
||||||
createTestExtensionPackage(t, pkgV1, "wrapper-ext", "1.0.0", js, nil)
|
|
||||||
createTestExtensionPackage(t, pkgV2, "wrapper-ext", "1.1.0", js, nil)
|
|
||||||
|
|
||||||
loadedJSON, err := LoadExtensionFromPath(pkgV1)
|
|
||||||
if err != nil || !strings.Contains(loadedJSON, "wrapper-ext") {
|
|
||||||
t.Fatalf("LoadExtensionFromPath = %q/%v", loadedJSON, err)
|
|
||||||
}
|
|
||||||
if installedJSON, err := GetInstalledExtensions(); err != nil || !strings.Contains(installedJSON, "wrapper-ext") {
|
|
||||||
t.Fatalf("GetInstalledExtensions = %q/%v", installedJSON, err)
|
|
||||||
}
|
|
||||||
if err := SetExtensionEnabledByID("wrapper-ext", true); err != nil {
|
|
||||||
t.Fatalf("SetExtensionEnabledByID true: %v", err)
|
|
||||||
}
|
|
||||||
if actionJSON, err := InvokeExtensionActionJSON("wrapper-ext", "doAction"); err != nil || !strings.Contains(actionJSON, "wrapped") {
|
|
||||||
t.Fatalf("InvokeExtensionActionJSON = %q/%v", actionJSON, err)
|
|
||||||
}
|
|
||||||
if upgradeJSON, err := CheckExtensionUpgradeFromPath(pkgV2); err != nil || !strings.Contains(upgradeJSON, `"can_upgrade":true`) {
|
|
||||||
t.Fatalf("CheckExtensionUpgradeFromPath = %q/%v", upgradeJSON, err)
|
|
||||||
}
|
|
||||||
if upgradedJSON, err := UpgradeExtensionFromPath(pkgV2); err != nil || !strings.Contains(upgradedJSON, "1.1.0") {
|
|
||||||
t.Fatalf("UpgradeExtensionFromPath = %q/%v", upgradedJSON, err)
|
|
||||||
}
|
|
||||||
if err := SetExtensionEnabledByID("wrapper-ext", false); err != nil {
|
|
||||||
t.Fatalf("SetExtensionEnabledByID false: %v", err)
|
|
||||||
}
|
|
||||||
if err := UnloadExtensionByID("wrapper-ext"); err != nil {
|
|
||||||
t.Fatalf("UnloadExtensionByID: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dirExt := filepath.Join(extensionsDir, "wrapper-dir-ext")
|
|
||||||
if err := createDirectoryExtension(dirExt, "wrapper-dir-ext", "1.0.0"); err != nil {
|
|
||||||
t.Fatalf("create directory extension: %v", err)
|
|
||||||
}
|
|
||||||
if loadedDirJSON, err := LoadExtensionsFromDir(extensionsDir); err != nil || !strings.Contains(loadedDirJSON, "wrapper-dir-ext") {
|
|
||||||
t.Fatalf("LoadExtensionsFromDir = %q/%v", loadedDirJSON, err)
|
|
||||||
}
|
|
||||||
if err := RemoveExtensionByID("wrapper-dir-ext"); err != nil {
|
|
||||||
t.Fatalf("RemoveExtensionByID: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func createDirectoryExtension(dir, name, version string) error {
|
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
manifest := fmt.Sprintf(`{"name":%q,"displayName":%q,"version":%q,"description":"Directory wrapper extension","type":["metadata_provider"],"permissions":{}}`, name, name, version)
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0600); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.WriteFile(filepath.Join(dir, "index.js"), []byte(`registerExtension({searchTracks:function(){return {tracks:[], total:0};}});`), 0600)
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLyricsExportWrappersWithoutNetwork(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
audioPath := filepath.Join(dir, "sidecar.mp3")
|
|
||||||
if err := os.WriteFile(audioPath, []byte("audio"), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "sidecar.lrc"), []byte("[00:00.00]Sidecar lyric"), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if jsonText, err := FetchLyrics("spotify-1", "Song Instrumental", "Artist", 180000); err != nil || !strings.Contains(jsonText, `"instrumental":true`) {
|
|
||||||
t.Fatalf("FetchLyrics instrumental = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
if lrc, err := GetLyricsLRC("spotify-1", "Song Instrumental", "Artist", "", 180000); err != nil || lrc != "[instrumental:true]" {
|
|
||||||
t.Fatalf("GetLyricsLRC instrumental = %q/%v", lrc, err)
|
|
||||||
}
|
|
||||||
if jsonText, err := GetLyricsLRCWithSource("spotify-1", "Song Instrumental", "Artist", "", 180000); err != nil || !strings.Contains(jsonText, `"instrumental":true`) {
|
|
||||||
t.Fatalf("GetLyricsLRCWithSource instrumental = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
if lrc, err := GetLyricsLRC("", "", "", audioPath, 0); err != nil || !strings.Contains(lrc, "Sidecar lyric") {
|
|
||||||
t.Fatalf("GetLyricsLRC sidecar = %q/%v", lrc, err)
|
|
||||||
}
|
|
||||||
if jsonText, err := GetLyricsLRCWithSource("", "", "", audioPath, 0); err != nil || !strings.Contains(jsonText, "Sidecar lyric") {
|
|
||||||
t.Fatalf("GetLyricsLRCWithSource sidecar = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
outPath := filepath.Join(dir, "lyrics.lrc")
|
|
||||||
if err := FetchAndSaveLyrics("Song", "Artist", "", 0, outPath, audioPath); err != nil {
|
|
||||||
t.Fatalf("FetchAndSaveLyrics sidecar: %v", err)
|
|
||||||
}
|
|
||||||
if data := string(mustReadFile(t, outPath)); !strings.Contains(data, "Sidecar lyric") {
|
|
||||||
t.Fatalf("saved lyrics = %q", data)
|
|
||||||
}
|
|
||||||
if response, err := EmbedLyricsToFile(filepath.Join(dir, "not-flac.mp3"), "lyrics"); err != nil || !strings.Contains(response, `"success":false`) {
|
|
||||||
t.Fatalf("EmbedLyricsToFile error = %q/%v", response, err)
|
|
||||||
}
|
|
||||||
if response, err := RewriteSplitArtistTagsExport(filepath.Join(dir, "not-flac.mp3"), "A;B", "A"); err != nil || !strings.Contains(response, `"success":false`) {
|
|
||||||
t.Fatalf("RewriteSplitArtistTagsExport error = %q/%v", response, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1}
|
|
||||||
}
|
|
||||||
globalSongLinkClient = &SongLinkClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
var body string
|
|
||||||
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"}}`
|
|
||||||
} 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"}}}`
|
|
||||||
} 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
|
|
||||||
})}}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
if tidalURL, err := GetTidalURLFromDeezerTrack("101"); err != nil || !strings.Contains(tidalURL, "tidal") {
|
|
||||||
t.Fatalf("GetTidalURLFromDeezerTrack = %q/%v", tidalURL, err)
|
|
||||||
}
|
|
||||||
if urls, err := NewSongLinkClient().GetStreamingURLs("spotify-1"); err != nil || urls["tidal"] == "" || urls["amazon"] == "" {
|
|
||||||
t.Fatalf("GetStreamingURLs = %#v/%v", urls, err)
|
|
||||||
}
|
|
||||||
if youtubeURL, err := NewSongLinkClient().GetYouTubeURLFromSpotify("spotify-1"); err != nil || !strings.Contains(youtubeURL, "youtu") {
|
|
||||||
t.Fatalf("GetYouTubeURLFromSpotify = %q/%v", youtubeURL, err)
|
|
||||||
}
|
|
||||||
if amazonURL, err := NewSongLinkClient().GetAmazonURLFromDeezer("101"); err != nil || !strings.Contains(amazonURL, "amazon") {
|
|
||||||
t.Fatalf("GetAmazonURLFromDeezer = %q/%v", amazonURL, err)
|
|
||||||
}
|
|
||||||
if youtubeURL, err := NewSongLinkClient().GetYouTubeURLFromDeezer("101"); err != nil || !strings.Contains(youtubeURL, "youtube") {
|
|
||||||
t.Fatalf("GetYouTubeURLFromDeezer = %q/%v", youtubeURL, err)
|
|
||||||
}
|
|
||||||
if deezerID, err := NewSongLinkClient().GetDeezerIDFromSpotify("spotify-1"); err != nil || deezerID != "101" {
|
|
||||||
t.Fatalf("GetDeezerIDFromSpotify = %q/%v", deezerID, err)
|
|
||||||
}
|
|
||||||
if album, err := NewSongLinkClient().CheckAlbumAvailability("album-1"); err != nil || !album.Deezer || album.DeezerID == "" {
|
|
||||||
t.Fatalf("CheckAlbumAvailability = %#v/%v", album, err)
|
|
||||||
}
|
|
||||||
if albumID, err := NewSongLinkClient().GetDeezerAlbumIDFromSpotify("album-1"); err != nil || albumID == "" {
|
|
||||||
t.Fatalf("GetDeezerAlbumIDFromSpotify = %q/%v", albumID, err)
|
|
||||||
}
|
|
||||||
if availability, err := NewSongLinkClient().CheckAvailabilityFromURL("https://www.deezer.com/track/101"); err != nil || !availability.Deezer {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
deezerClient = &DeezerClient{
|
|
||||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
|
|
||||||
if body == "" {
|
|
||||||
body = `{"error":"missing"}`
|
|
||||||
}
|
|
||||||
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
|
|
||||||
})},
|
|
||||||
searchCache: map[string]*cacheEntry{},
|
|
||||||
albumCache: map[string]*cacheEntry{},
|
|
||||||
artistCache: map[string]*cacheEntry{},
|
|
||||||
isrcCache: map[string]string{},
|
|
||||||
cacheCleanupInterval: time.Hour,
|
|
||||||
}
|
|
||||||
deezerClientOnce.Do(func() {})
|
|
||||||
if jsonText, err := ConvertSpotifyToDeezer("track", "spotify-1"); err != nil || !strings.Contains(jsonText, `"spotify_id":"deezer:101"`) {
|
|
||||||
t.Fatalf("ConvertSpotifyToDeezer track = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
if jsonText, err := ConvertSpotifyToDeezer("album", "album-1"); err != nil || jsonText == "" {
|
|
||||||
t.Fatalf("ConvertSpotifyToDeezer album = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,493 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
|
|
||||||
got := classifyDownloadErrorType("All providers failed. Last error: HTTP status 429: too many requests")
|
|
||||||
if got != "rate_limit" {
|
|
||||||
t.Fatalf("expected rate_limit, got %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
responseJSON, err := errorResponse("All services failed. Last error: rate limit exceeded")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("errorResponse returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var response DownloadResponse
|
|
||||||
if err := json.Unmarshal([]byte(responseJSON), &response); err != nil {
|
|
||||||
t.Fatalf("invalid response JSON: %v", err)
|
|
||||||
}
|
|
||||||
if response.ErrorType != "rate_limit" {
|
|
||||||
t.Fatalf("expected rate_limit response, got %q", response.ErrorType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
dataDir := filepath.Join(dir, "data")
|
|
||||||
extensionsDir := filepath.Join(dir, "extensions")
|
|
||||||
if err := InitExtensionSystem(extensionsDir, dataDir); err != nil {
|
|
||||||
t.Fatalf("InitExtensionSystem: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider, ExtensionTypeDownloadProvider, ExtensionTypeLyricsProvider)
|
|
||||||
manager := getExtensionManager()
|
|
||||||
manager.mu.Lock()
|
|
||||||
if manager.extensions == nil {
|
|
||||||
manager.extensions = map[string]*loadedExtension{}
|
|
||||||
}
|
|
||||||
manager.extensions[ext.ID] = ext
|
|
||||||
manager.mu.Unlock()
|
|
||||||
defer func() {
|
|
||||||
manager.mu.Lock()
|
|
||||||
delete(manager.extensions, ext.ID)
|
|
||||||
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")
|
|
||||||
ClearItemProgress("item-1")
|
|
||||||
CancelDownload("item-1")
|
|
||||||
if GetDownloadProgress() == "" || GetAllDownloadProgress() == "" || GetAllDownloadProgressDelta(0) == "" {
|
|
||||||
t.Fatal("expected progress JSON")
|
|
||||||
}
|
|
||||||
CleanupConnections()
|
|
||||||
|
|
||||||
cuePath, audioPath := writeExportCueFixture(t, dir)
|
|
||||||
if jsonText, err := ParseCueSheet(cuePath, ""); err != nil {
|
|
||||||
t.Fatalf("ParseCueSheet = %q/%v", jsonText, err)
|
|
||||||
} else {
|
|
||||||
var parsed CueSplitInfo
|
|
||||||
if err := json.Unmarshal([]byte(jsonText), &parsed); err != nil {
|
|
||||||
t.Fatalf("decode ParseCueSheet: %v", err)
|
|
||||||
}
|
|
||||||
if parsed.AudioPath != audioPath {
|
|
||||||
t.Fatalf("ParseCueSheet audio path = %q want %q", parsed.AudioPath, audioPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if jsonText, err := ScanCueSheetForLibrary(cuePath, "", "virtual.cue", 111); err != nil || !strings.Contains(jsonText, "cue+wav") {
|
|
||||||
t.Fatalf("ScanCueSheetForLibrary = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
if jsonText, err := ScanCueSheetForLibraryWithCoverCacheKey(cuePath, "", "virtual.cue", 111, "cover-key"); err != nil || !strings.Contains(jsonText, "cue+wav") {
|
|
||||||
t.Fatalf("ScanCueSheetForLibraryWithCoverCacheKey = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
apePath := filepath.Join(dir, "edit.ape")
|
|
||||||
if err := os.WriteFile(apePath, []byte("audio"), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
editJSON := `{"title":"Edited","artist":"Artist","track_number":"1","track_total":"2","disc_number":"1","disc_total":"1"}`
|
|
||||||
if response, err := EditFileMetadata(apePath, editJSON); err != nil || !strings.Contains(response, "native_ape") {
|
|
||||||
t.Fatalf("EditFileMetadata ape = %q/%v", response, err)
|
|
||||||
}
|
|
||||||
if response, err := EditFileMetadata(filepath.Join(dir, "edit.mp3"), editJSON); err != nil || !strings.Contains(response, "ffmpeg") {
|
|
||||||
t.Fatalf("EditFileMetadata ffmpeg = %q/%v", response, err)
|
|
||||||
}
|
|
||||||
misnamedM4APath := filepath.Join(dir, "misnamed.flac")
|
|
||||||
if err := os.WriteFile(misnamedM4APath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "Misnamed"), true), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
replayGainJSON := `{"replaygain_track_gain":"-1 dB","replaygain_track_peak":"0.9"}`
|
|
||||||
if response, err := EditFileMetadata(misnamedM4APath, replayGainJSON); err != nil || !strings.Contains(response, "native_m4a_replaygain") {
|
|
||||||
t.Fatalf("EditFileMetadata misnamed m4a replaygain = %q/%v", response, err)
|
|
||||||
}
|
|
||||||
if _, err := EditFileMetadata(apePath, `not-json`); err == nil {
|
|
||||||
t.Fatal("expected invalid metadata JSON")
|
|
||||||
}
|
|
||||||
if !hasOnlyM4AReplayGainFields(map[string]string{"replaygain_track_gain": "-1 dB"}) {
|
|
||||||
t.Fatal("expected replaygain-only fields")
|
|
||||||
}
|
|
||||||
if hasOnlyM4AReplayGainFields(map[string]string{"title": "Song"}) {
|
|
||||||
t.Fatal("expected non-replaygain field rejection")
|
|
||||||
}
|
|
||||||
|
|
||||||
AllowDownloadDir(dir)
|
|
||||||
if err := SetDownloadDirectory(dir); err != nil {
|
|
||||||
t.Fatalf("SetDownloadDirectory: %v", err)
|
|
||||||
}
|
|
||||||
if duplicateJSON, err := CheckDuplicate(dir, ""); err != nil || !strings.Contains(duplicateJSON, "exists") {
|
|
||||||
t.Fatalf("CheckDuplicate = %q/%v", duplicateJSON, err)
|
|
||||||
}
|
|
||||||
if batchJSON, err := CheckDuplicatesBatch(dir, `[{"isrc":"","track_name":"Song","artist_name":"Artist"}]`); err != nil || !strings.Contains(batchJSON, "Song") {
|
|
||||||
t.Fatalf("CheckDuplicatesBatch = %q/%v", batchJSON, err)
|
|
||||||
}
|
|
||||||
_ = PreBuildDuplicateIndex(dir)
|
|
||||||
InvalidateDuplicateIndex(dir)
|
|
||||||
if filename, err := BuildFilename("{artist} - {title}", `{"artist":"A/B","title":"Song?"}`); err != nil || filename == "" {
|
|
||||||
t.Fatalf("BuildFilename = %q/%v", filename, err)
|
|
||||||
}
|
|
||||||
if _, err := BuildFilename("{title}", `not-json`); err == nil {
|
|
||||||
t.Fatal("expected BuildFilename JSON error")
|
|
||||||
}
|
|
||||||
if got := SanitizeFilename(`A/B:C*D?`); strings.ContainsAny(got, `/:*?`) {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
ClearTrackIDCache()
|
|
||||||
|
|
||||||
if err := SetLyricsProvidersJSON(`["lrclib","apple_music"]`); err != nil {
|
|
||||||
t.Fatalf("SetLyricsProvidersJSON: %v", err)
|
|
||||||
}
|
|
||||||
if providers, err := GetLyricsProvidersJSON(); err != nil || !strings.Contains(providers, "lrclib") {
|
|
||||||
t.Fatalf("GetLyricsProvidersJSON = %q/%v", providers, err)
|
|
||||||
}
|
|
||||||
if available, err := GetAvailableLyricsProvidersJSON(); err != nil || available == "" {
|
|
||||||
t.Fatalf("GetAvailableLyricsProvidersJSON = %q/%v", available, err)
|
|
||||||
}
|
|
||||||
if err := SetLyricsFetchOptionsJSON(`{"include_translation_netease":true}`); err != nil {
|
|
||||||
t.Fatalf("SetLyricsFetchOptionsJSON: %v", err)
|
|
||||||
}
|
|
||||||
if opts, err := GetLyricsFetchOptionsJSON(); err != nil || opts == "" {
|
|
||||||
t.Fatalf("GetLyricsFetchOptionsJSON = %q/%v", opts, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := SetProviderPriorityJSON(`["coverage-ext"]`); err != nil {
|
|
||||||
t.Fatalf("SetProviderPriorityJSON: %v", err)
|
|
||||||
}
|
|
||||||
if jsonText, err := GetProviderPriorityJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
|
|
||||||
t.Fatalf("GetProviderPriorityJSON = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
if err := SetMetadataProviderPriorityJSON(`["coverage-ext"]`); err != nil {
|
|
||||||
t.Fatalf("SetMetadataProviderPriorityJSON: %v", err)
|
|
||||||
}
|
|
||||||
if jsonText, err := GetMetadataProviderPriorityJSON(); err != nil || !strings.Contains(jsonText, "coverage-ext") {
|
|
||||||
t.Fatalf("GetMetadataProviderPriorityJSON = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := SetExtensionSettingsJSON(ext.ID, `{"quality":"lossless","_secret":"hidden"}`); err != nil {
|
|
||||||
t.Fatalf("SetExtensionSettingsJSON: %v", err)
|
|
||||||
}
|
|
||||||
if settingsJSON, err := GetExtensionSettingsJSON(ext.ID); err != nil || !strings.Contains(settingsJSON, "quality") {
|
|
||||||
t.Fatalf("GetExtensionSettingsJSON = %q/%v", settingsJSON, err)
|
|
||||||
}
|
|
||||||
if err := SetExtensionSettingsJSON(ext.ID, `not-json`); err == nil {
|
|
||||||
t.Fatal("expected settings JSON error")
|
|
||||||
}
|
|
||||||
|
|
||||||
if jsonText, err := SearchTracksWithExtensionsJSON("song", 5); err != nil || !strings.Contains(jsonText, "search-1") {
|
|
||||||
t.Fatalf("SearchTracksWithExtensionsJSON = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
if jsonText, err := SearchTracksWithMetadataProvidersJSON("song", 5, true); err != nil || !strings.Contains(jsonText, "search-1") {
|
|
||||||
t.Fatalf("SearchTracksWithMetadataProvidersJSON = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
if jsonText, err := GetProviderMetadataJSON(ext.ID, "track", "track-1"); err != nil || !strings.Contains(jsonText, "Track track-1") {
|
|
||||||
t.Fatalf("GetProviderMetadataJSON track = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
for _, resourceType := range []string{"album", "playlist", "artist"} {
|
|
||||||
if jsonText, err := GetProviderMetadataJSON(ext.ID, resourceType, resourceType+"-1"); err != nil || jsonText == "" {
|
|
||||||
t.Fatalf("GetProviderMetadataJSON %s = %q/%v", resourceType, jsonText, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := GetProviderMetadataJSON("", "track", "id"); err == nil {
|
|
||||||
t.Fatal("expected empty provider ID error")
|
|
||||||
}
|
|
||||||
if _, err := GetProviderMetadataJSON(ext.ID, "unsupported", "id"); err == nil {
|
|
||||||
t.Fatal("expected unsupported provider type")
|
|
||||||
}
|
|
||||||
if firstNonEmptyTrimmed(" ", " value ") != "value" {
|
|
||||||
t.Fatal("expected first trimmed value")
|
|
||||||
}
|
|
||||||
requestJSON := `{"use_extensions":true,"use_fallback":false,"service":"coverage-ext","source":"coverage-ext","track_name":"Song","artist_name":"Artist","album_name":"Album","output_dir":"` + escapeJSONPath(dir) + `","output_ext":".flac","quality":"LOSSLESS"}`
|
|
||||||
if jsonText, err := DownloadWithExtensionsJSON(requestJSON); err != nil || !strings.Contains(jsonText, "coverage-ext") {
|
|
||||||
t.Fatalf("DownloadWithExtensionsJSON = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
if _, err := DownloadWithExtensionsJSON(`not-json`); err == nil {
|
|
||||||
t.Fatal("expected DownloadWithExtensionsJSON JSON error")
|
|
||||||
}
|
|
||||||
|
|
||||||
SetExtensionAuthCodeByID(ext.ID, "code")
|
|
||||||
SetExtensionTokensByID(ext.ID, "access", "refresh", 60)
|
|
||||||
if !IsExtensionAuthenticatedByID(ext.ID) {
|
|
||||||
t.Fatal("expected authenticated extension")
|
|
||||||
}
|
|
||||||
if pending, err := GetExtensionPendingAuthJSON(ext.ID); err != nil || pending != "" {
|
|
||||||
t.Fatalf("GetExtensionPendingAuthJSON = %q/%v", pending, err)
|
|
||||||
}
|
|
||||||
ClearExtensionPendingAuthByID(ext.ID)
|
|
||||||
if all, err := GetAllPendingAuthRequestsJSON(); err != nil || all == "" {
|
|
||||||
t.Fatalf("GetAllPendingAuthRequestsJSON = %q/%v", all, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ffmpegCommandsMu.Lock()
|
|
||||||
ffmpegCommands["cmd-1"] = &FFmpegCommand{ExtensionID: ext.ID, Command: "ffmpeg -version", InputPath: "in", OutputPath: "out"}
|
|
||||||
ffmpegCommandsMu.Unlock()
|
|
||||||
if cmdJSON, err := GetPendingFFmpegCommandJSON("cmd-1"); err != nil || !strings.Contains(cmdJSON, "cmd-1") {
|
|
||||||
t.Fatalf("GetPendingFFmpegCommandJSON = %q/%v", cmdJSON, err)
|
|
||||||
}
|
|
||||||
if all, err := GetAllPendingFFmpegCommandsJSON(); err != nil || !strings.Contains(all, "cmd-1") {
|
|
||||||
t.Fatalf("GetAllPendingFFmpegCommandsJSON = %q/%v", all, err)
|
|
||||||
}
|
|
||||||
SetFFmpegCommandResultByID("cmd-1", true, "ok", "")
|
|
||||||
ClearFFmpegCommand("cmd-1")
|
|
||||||
if empty, err := GetPendingFFmpegCommandJSON("missing"); err != nil || empty != "" {
|
|
||||||
t.Fatalf("missing ffmpeg = %q/%v", empty, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
enrichedJSON, err := EnrichTrackWithExtensionJSON(ext.ID, `{"id":"track-1","name":"Old","artists":"Artist"}`)
|
|
||||||
if err != nil || !strings.Contains(enrichedJSON, "Enriched") {
|
|
||||||
t.Fatalf("EnrichTrackWithExtensionJSON = %q/%v", enrichedJSON, err)
|
|
||||||
}
|
|
||||||
if sameJSON, err := EnrichTrackWithExtensionJSON("missing", `{"name":"Old"}`); err != nil || !strings.Contains(sameJSON, "Old") {
|
|
||||||
t.Fatalf("missing EnrichTrackWithExtensionJSON = %q/%v", sameJSON, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
deezerClient = &DeezerClient{
|
|
||||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
body := fakeDeezerResponse(req.URL.Path, req.URL.RawQuery)
|
|
||||||
status := http.StatusOK
|
|
||||||
if body == "" {
|
|
||||||
status = http.StatusNotFound
|
|
||||||
body = `{"error":"missing"}`
|
|
||||||
}
|
|
||||||
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
|
|
||||||
})},
|
|
||||||
searchCache: map[string]*cacheEntry{},
|
|
||||||
albumCache: map[string]*cacheEntry{},
|
|
||||||
artistCache: map[string]*cacheEntry{},
|
|
||||||
isrcCache: map[string]string{},
|
|
||||||
cacheCleanupInterval: time.Hour,
|
|
||||||
}
|
|
||||||
deezerClientOnce.Do(func() {})
|
|
||||||
for _, item := range []struct {
|
|
||||||
typ string
|
|
||||||
id string
|
|
||||||
}{
|
|
||||||
{"track", "101"},
|
|
||||||
{"album", "201"},
|
|
||||||
{"artist", "301"},
|
|
||||||
{"playlist", "401"},
|
|
||||||
} {
|
|
||||||
if jsonText, err := GetDeezerMetadata(item.typ, item.id); err != nil || jsonText == "" {
|
|
||||||
t.Fatalf("GetDeezerMetadata %s = %q/%v", item.typ, jsonText, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := GetDeezerMetadata("bad", "1"); err == nil {
|
|
||||||
t.Fatal("expected unsupported Deezer metadata type")
|
|
||||||
}
|
|
||||||
if jsonText, err := GetDeezerRelatedArtists("301", 2); err != nil || !strings.Contains(jsonText, "Related") {
|
|
||||||
t.Fatalf("GetDeezerRelatedArtists = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
if jsonText, err := GetDeezerExtendedMetadata("101"); err != nil || !strings.Contains(jsonText, "Label") {
|
|
||||||
t.Fatalf("GetDeezerExtendedMetadata = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
if _, err := GetDeezerExtendedMetadata(""); err == nil {
|
|
||||||
t.Fatal("expected empty Deezer metadata ID error")
|
|
||||||
}
|
|
||||||
if jsonText, err := SearchDeezerByISRC("USRC17607839"); err != nil || !strings.Contains(jsonText, "deezer:101") {
|
|
||||||
t.Fatalf("SearchDeezerByISRC = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
if jsonText, err := SearchDeezerByISRCForItemID("USRC17607839", "item-isrc"); err != nil || !strings.Contains(jsonText, "deezer:101") {
|
|
||||||
t.Fatalf("SearchDeezerByISRCForItemID = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
customJSON, err := CustomSearchWithExtensionJSON(ext.ID, "needle", `{"filter":"tracks"}`)
|
|
||||||
if err != nil || !strings.Contains(customJSON, "Custom needle") {
|
|
||||||
t.Fatalf("CustomSearchWithExtensionJSON = %q/%v", customJSON, err)
|
|
||||||
}
|
|
||||||
if customJSON, err := CustomSearchWithExtensionJSONWithRequestID(ext.ID, "needle", `not-json`, "req-custom"); err != nil || !strings.Contains(customJSON, "custom-1") {
|
|
||||||
t.Fatalf("CustomSearchWithExtensionJSONWithRequestID = %q/%v", customJSON, err)
|
|
||||||
}
|
|
||||||
if providersJSON, err := GetSearchProvidersJSON(); err != nil || !strings.Contains(providersJSON, "coverage-ext") {
|
|
||||||
t.Fatalf("GetSearchProvidersJSON = %q/%v", providersJSON, err)
|
|
||||||
}
|
|
||||||
if found := FindURLHandlerJSON("https://example.test/track/1"); found != ext.ID {
|
|
||||||
t.Fatalf("FindURLHandlerJSON = %q", found)
|
|
||||||
}
|
|
||||||
if handlersJSON, err := GetURLHandlersJSON(); err != nil || !strings.Contains(handlersJSON, "coverage-ext") {
|
|
||||||
t.Fatalf("GetURLHandlersJSON = %q/%v", handlersJSON, err)
|
|
||||||
}
|
|
||||||
if handledJSON, err := HandleURLWithExtensionJSON("https://example.test/track/1"); err != nil || !strings.Contains(handledJSON, "url-track") {
|
|
||||||
t.Fatalf("HandleURLWithExtensionJSON = %q/%v", handledJSON, err)
|
|
||||||
}
|
|
||||||
if postJSON, err := RunPostProcessingJSON(filepath.Join(dir, "song.flac"), `{"title":"Song"}`); err != nil || !strings.Contains(postJSON, "success") {
|
|
||||||
t.Fatalf("RunPostProcessingJSON = %q/%v", postJSON, err)
|
|
||||||
}
|
|
||||||
v2Input := `{"path":"` + escapeJSONPath(filepath.Join(dir, "song.flac")) + `","uri":"content://song","name":"song.flac","mime_type":"audio/flac","size":10}`
|
|
||||||
if postJSON, err := RunPostProcessingV2JSON(v2Input, `not-json`); err != nil || !strings.Contains(postJSON, "success") {
|
|
||||||
t.Fatalf("RunPostProcessingV2JSON = %q/%v", postJSON, err)
|
|
||||||
}
|
|
||||||
if postProviders, err := GetPostProcessingProvidersJSON(); err != nil || !strings.Contains(postProviders, "hook") {
|
|
||||||
t.Fatalf("GetPostProcessingProvidersJSON = %q/%v", postProviders, err)
|
|
||||||
}
|
|
||||||
if feedJSON, err := GetExtensionHomeFeedJSON(ext.ID); err != nil || !strings.Contains(feedJSON, "home-1") {
|
|
||||||
t.Fatalf("GetExtensionHomeFeedJSON = %q/%v", feedJSON, err)
|
|
||||||
}
|
|
||||||
if feedJSON, err := GetExtensionHomeFeedJSONWithRequestID(ext.ID, "req-home"); err != nil || !strings.Contains(feedJSON, "home-1") {
|
|
||||||
t.Fatalf("GetExtensionHomeFeedJSONWithRequestID = %q/%v", feedJSON, err)
|
|
||||||
}
|
|
||||||
if categoriesJSON, err := GetExtensionBrowseCategoriesJSON(ext.ID); err != nil || !strings.Contains(categoriesJSON, "cat-1") {
|
|
||||||
t.Fatalf("GetExtensionBrowseCategoriesJSON = %q/%v", categoriesJSON, err)
|
|
||||||
}
|
|
||||||
CancelExtensionRequestJSON("req-home")
|
|
||||||
|
|
||||||
storeDir := filepath.Join(dir, "store")
|
|
||||||
if err := InitExtensionStoreJSON(storeDir); err != nil {
|
|
||||||
t.Fatalf("InitExtensionStoreJSON: %v", err)
|
|
||||||
}
|
|
||||||
if err := SetStoreRegistryURLJSON("https://registry.example.com/index.json"); err != nil {
|
|
||||||
t.Fatalf("SetStoreRegistryURLJSON: %v", err)
|
|
||||||
}
|
|
||||||
store := getExtensionStore()
|
|
||||||
store.cache = &storeRegistry{Extensions: []storeExtension{{
|
|
||||||
ID: "coverage-ext",
|
|
||||||
Name: "coverage-ext",
|
|
||||||
Version: "1.0.0",
|
|
||||||
Description: "Coverage",
|
|
||||||
Category: CategoryMetadata,
|
|
||||||
Tags: []string{"metadata"},
|
|
||||||
DownloadURL: "https://registry.example.com/coverage.spotiflac-ext",
|
|
||||||
}}}
|
|
||||||
store.cacheTime = time.Now()
|
|
||||||
if registryURL, err := GetStoreRegistryURLJSON(); err != nil || registryURL == "" {
|
|
||||||
t.Fatalf("GetStoreRegistryURLJSON = %q/%v", registryURL, err)
|
|
||||||
}
|
|
||||||
if storeJSON, err := GetStoreExtensionsJSON(false); err != nil || !strings.Contains(storeJSON, "coverage-ext") {
|
|
||||||
t.Fatalf("GetStoreExtensionsJSON = %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 := GetStoreCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
|
|
||||||
t.Fatalf("GetStoreCategoriesJSON = %q/%v", catsJSON, err)
|
|
||||||
}
|
|
||||||
if dest, err := buildStoreExtensionDestPath(
|
|
||||||
dir,
|
|
||||||
"coverage/ext",
|
|
||||||
"https://registry.example.com/coverage.spotiflac-ext",
|
|
||||||
); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
|
|
||||||
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
|
|
||||||
}
|
|
||||||
if dest, err := buildStoreExtensionDestPath(
|
|
||||||
dir,
|
|
||||||
"coverage/ext",
|
|
||||||
"https://registry.example.com/coverage.sflx",
|
|
||||||
); err != nil || !strings.HasSuffix(dest, ".sflx") {
|
|
||||||
t.Fatalf("buildStoreExtensionDestPath sflx = %q/%v", dest, err)
|
|
||||||
}
|
|
||||||
if _, err := buildStoreExtensionDestPath(
|
|
||||||
dir,
|
|
||||||
" ",
|
|
||||||
"https://registry.example.com/coverage.sflx",
|
|
||||||
); err == nil {
|
|
||||||
t.Fatal("expected invalid extension id")
|
|
||||||
}
|
|
||||||
if err := ClearStoreCacheJSON(); err != nil {
|
|
||||||
t.Fatalf("ClearStoreCacheJSON: %v", err)
|
|
||||||
}
|
|
||||||
if err := ClearStoreRegistryURLJSON(); err != nil {
|
|
||||||
t.Fatalf("ClearStoreRegistryURLJSON: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
SetLibraryCoverCacheDirJSON(filepath.Join(dir, "covers"))
|
|
||||||
libraryDir := filepath.Join(dir, "library")
|
|
||||||
if err := os.MkdirAll(libraryDir, 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(libraryDir, "Artist - Song.mp3"), []byte("not mp3"), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if scanJSON, err := ScanLibraryFolderJSON(libraryDir); err != nil || !strings.Contains(scanJSON, "Song") {
|
|
||||||
t.Fatalf("ScanLibraryFolderJSON = %q/%v", scanJSON, err)
|
|
||||||
}
|
|
||||||
if scanJSON, err := ScanLibraryFolderIncrementalJSON(libraryDir, `[]`); err != nil || !strings.Contains(scanJSON, "Song") {
|
|
||||||
t.Fatalf("ScanLibraryFolderIncrementalJSON = %q/%v", scanJSON, err)
|
|
||||||
}
|
|
||||||
snapshotPath := filepath.Join(dir, "snapshot.json")
|
|
||||||
if err := os.WriteFile(snapshotPath, []byte(`[]`), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if scanJSON, err := ScanLibraryFolderIncrementalFromSnapshotJSON(libraryDir, snapshotPath); err != nil || !strings.Contains(scanJSON, "Song") {
|
|
||||||
t.Fatalf("ScanLibraryFolderIncrementalFromSnapshotJSON = %q/%v", scanJSON, err)
|
|
||||||
}
|
|
||||||
if GetLibraryScanProgressJSON() == "" {
|
|
||||||
t.Fatal("expected scan progress JSON")
|
|
||||||
}
|
|
||||||
CancelLibraryScanJSON()
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +1,6 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import "testing"
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) {
|
|
||||||
original := GetExtensionFallbackProviderIDs()
|
|
||||||
defer SetExtensionFallbackProviderIDs(original)
|
|
||||||
|
|
||||||
SetExtensionFallbackProviderIDs([]string{"custom-ext"})
|
|
||||||
|
|
||||||
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
|
|
||||||
t.Fatalf("SetExtensionFallbackProviderIDsJSON returned error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := GetExtensionFallbackProviderIDs(); got != nil {
|
|
||||||
t.Fatalf("expected nil fallback provider list after reset, got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
|
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
|
||||||
req := DownloadRequest{
|
req := DownloadRequest{
|
||||||
@@ -133,216 +114,6 @@ func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildDownloadSuccessResponseNormalizesDecryptionDescriptor(t *testing.T) {
|
|
||||||
req := DownloadRequest{
|
|
||||||
TrackName: "Track",
|
|
||||||
ArtistName: "Artist",
|
|
||||||
}
|
|
||||||
|
|
||||||
result := DownloadResult{
|
|
||||||
Title: "Track",
|
|
||||||
Artist: "Artist",
|
|
||||||
DecryptionKey: "00112233",
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := buildDownloadSuccessResponse(
|
|
||||||
req,
|
|
||||||
result,
|
|
||||||
"amazon",
|
|
||||||
"ok",
|
|
||||||
"/tmp/test.m4a",
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.Decryption == nil {
|
|
||||||
t.Fatal("expected decryption descriptor to be present")
|
|
||||||
}
|
|
||||||
if resp.Decryption.Strategy != genericFFmpegMOVDecryptionStrategy {
|
|
||||||
t.Fatalf("strategy = %q", resp.Decryption.Strategy)
|
|
||||||
}
|
|
||||||
if resp.Decryption.Key != result.DecryptionKey {
|
|
||||||
t.Fatalf("key = %q, want %q", resp.Decryption.Key, result.DecryptionKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatMusicBrainzGenrePrefersHighestCountTag(t *testing.T) {
|
|
||||||
got := formatMusicBrainzGenre([]musicBrainzTag{
|
|
||||||
{Name: "art pop", Count: 3},
|
|
||||||
{Name: "pop", Count: 8},
|
|
||||||
{Name: "dance pop", Count: 5},
|
|
||||||
})
|
|
||||||
|
|
||||||
if got != "Pop" {
|
|
||||||
t.Fatalf("genre = %q, want %q", got, "Pop")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSelectMusicBrainzAlbumArtistPrefersMatchingRelease(t *testing.T) {
|
|
||||||
releases := []musicBrainzRelease{
|
|
||||||
{
|
|
||||||
Title: "Other Album",
|
|
||||||
ArtistCredit: []musicBrainzArtistCredit{
|
|
||||||
{Name: "Wrong Artist"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Title: "Target Album",
|
|
||||||
ArtistCredit: []musicBrainzArtistCredit{
|
|
||||||
{Name: "Artist A", JoinPhrase: " & "},
|
|
||||||
{Name: "Artist B"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
got := selectMusicBrainzAlbumArtist(releases, "Target Album")
|
|
||||||
if got != "Artist A & Artist B" {
|
|
||||||
t.Fatalf("album artist = %q, want matching release artist credit", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnrichRequestExtendedMetadataUsesMusicBrainzAlbumArtist(t *testing.T) {
|
|
||||||
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
|
|
||||||
origMusicBrainzGenreFetcher := fetchMusicBrainzGenreByISRC
|
|
||||||
origMusicBrainzAlbumArtistFetcher := fetchMusicBrainzAlbumArtistByISRC
|
|
||||||
defer func() {
|
|
||||||
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
|
|
||||||
fetchMusicBrainzGenreByISRC = origMusicBrainzGenreFetcher
|
|
||||||
fetchMusicBrainzAlbumArtistByISRC = origMusicBrainzAlbumArtistFetcher
|
|
||||||
}()
|
|
||||||
|
|
||||||
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
|
||||||
return &AlbumExtendedMetadata{}, nil
|
|
||||||
}
|
|
||||||
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
|
|
||||||
return "", fmt.Errorf("no genre")
|
|
||||||
}
|
|
||||||
fetchMusicBrainzAlbumArtistByISRC = func(isrc string, albumName string) (string, error) {
|
|
||||||
if isrc != "TESTISRC" || albumName != "Target Album" {
|
|
||||||
t.Fatalf("unexpected MusicBrainz args: %q / %q", isrc, albumName)
|
|
||||||
}
|
|
||||||
return "MusicBrainz Album Artist", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
req := DownloadRequest{
|
|
||||||
ISRC: "TESTISRC",
|
|
||||||
ArtistName: "Track Artist",
|
|
||||||
AlbumName: "Target Album",
|
|
||||||
}
|
|
||||||
|
|
||||||
enrichRequestExtendedMetadata(&req)
|
|
||||||
|
|
||||||
if req.AlbumArtist != "MusicBrainz Album Artist" {
|
|
||||||
t.Fatalf("album artist = %q, want MusicBrainz value", req.AlbumArtist)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnrichRequestExtendedMetadataDoesNotFallbackAlbumArtistToTrackArtist(t *testing.T) {
|
|
||||||
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
|
|
||||||
origMusicBrainzGenreFetcher := fetchMusicBrainzGenreByISRC
|
|
||||||
origMusicBrainzAlbumArtistFetcher := fetchMusicBrainzAlbumArtistByISRC
|
|
||||||
defer func() {
|
|
||||||
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
|
|
||||||
fetchMusicBrainzGenreByISRC = origMusicBrainzGenreFetcher
|
|
||||||
fetchMusicBrainzAlbumArtistByISRC = origMusicBrainzAlbumArtistFetcher
|
|
||||||
}()
|
|
||||||
|
|
||||||
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
|
||||||
return &AlbumExtendedMetadata{}, nil
|
|
||||||
}
|
|
||||||
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
|
|
||||||
return "", fmt.Errorf("no genre")
|
|
||||||
}
|
|
||||||
fetchMusicBrainzAlbumArtistByISRC = func(isrc string, albumName string) (string, error) {
|
|
||||||
return "", fmt.Errorf("no album artist")
|
|
||||||
}
|
|
||||||
|
|
||||||
req := DownloadRequest{
|
|
||||||
ISRC: "TESTISRC",
|
|
||||||
ArtistName: "Track Artist",
|
|
||||||
AlbumName: "Target Album",
|
|
||||||
}
|
|
||||||
|
|
||||||
enrichRequestExtendedMetadata(&req)
|
|
||||||
|
|
||||||
if req.AlbumArtist != "" {
|
|
||||||
t.Fatalf("album artist = %q, want empty when MusicBrainz has no value", req.AlbumArtist)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnrichExtraMetadataByISRCFallsBackToMusicBrainzGenre(t *testing.T) {
|
|
||||||
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
|
|
||||||
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
|
|
||||||
defer func() {
|
|
||||||
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
|
|
||||||
fetchMusicBrainzGenreByISRC = origMusicBrainzFetcher
|
|
||||||
}()
|
|
||||||
|
|
||||||
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
|
|
||||||
if isrc != "TEST123" {
|
|
||||||
t.Fatalf("unexpected isrc: %q", isrc)
|
|
||||||
}
|
|
||||||
return "Alternative Rock", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
genre := ""
|
|
||||||
label := ""
|
|
||||||
copyright := ""
|
|
||||||
enrichExtraMetadataByISRC("DownloadWithFallback", "TEST123", &genre, &label, ©right)
|
|
||||||
|
|
||||||
if genre != "Alternative Rock" {
|
|
||||||
t.Fatalf("genre = %q, want fallback genre", genre)
|
|
||||||
}
|
|
||||||
if label != "" {
|
|
||||||
t.Fatalf("label = %q, want empty", label)
|
|
||||||
}
|
|
||||||
if copyright != "" {
|
|
||||||
t.Fatalf("copyright = %q, want empty", copyright)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnrichExtraMetadataByISRCPrefersDeezerGenre(t *testing.T) {
|
|
||||||
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
|
|
||||||
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
|
|
||||||
defer func() {
|
|
||||||
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
|
|
||||||
fetchMusicBrainzGenreByISRC = origMusicBrainzFetcher
|
|
||||||
}()
|
|
||||||
|
|
||||||
musicBrainzCalled := false
|
|
||||||
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
|
||||||
return &AlbumExtendedMetadata{
|
|
||||||
Genre: "Synthpop",
|
|
||||||
Label: "EMI",
|
|
||||||
Copyright: "(C) Test",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
|
|
||||||
musicBrainzCalled = true
|
|
||||||
return "Rock", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
genre := ""
|
|
||||||
label := ""
|
|
||||||
copyright := ""
|
|
||||||
enrichExtraMetadataByISRC("DownloadWithFallback", "TEST456", &genre, &label, ©right)
|
|
||||||
|
|
||||||
if genre != "Synthpop" {
|
|
||||||
t.Fatalf("genre = %q, want Deezer genre", genre)
|
|
||||||
}
|
|
||||||
if label != "EMI" {
|
|
||||||
t.Fatalf("label = %q, want Deezer label", label)
|
|
||||||
}
|
|
||||||
if copyright != "(C) Test" {
|
|
||||||
t.Fatalf("copyright = %q, want Deezer copyright", copyright)
|
|
||||||
}
|
|
||||||
if musicBrainzCalled {
|
|
||||||
t.Fatal("expected MusicBrainz not to be called when Deezer already provides genre")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
|
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
|
||||||
req := reEnrichRequest{
|
req := reEnrichRequest{
|
||||||
SpotifyID: "spotify-track-id",
|
SpotifyID: "spotify-track-id",
|
||||||
@@ -407,90 +178,6 @@ func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSelectBestReEnrichTrackRejectsMismatchedSearchResults(t *testing.T) {
|
|
||||||
req := reEnrichRequest{
|
|
||||||
TrackName: "Song Title",
|
|
||||||
ArtistName: "Artist Name",
|
|
||||||
AlbumName: "Album Name",
|
|
||||||
DurationMs: 180000,
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks := []ExtTrackMetadata{
|
|
||||||
{
|
|
||||||
ID: "wrong-rich-metadata",
|
|
||||||
Name: "Different Song",
|
|
||||||
Artists: "Different Artist",
|
|
||||||
AlbumName: "Album Name",
|
|
||||||
DurationMS: 180000,
|
|
||||||
ReleaseDate: "2024-03-09",
|
|
||||||
TrackNumber: 4,
|
|
||||||
DiscNumber: 1,
|
|
||||||
ISRC: "WRONG1234567",
|
|
||||||
ProviderID: "deezer",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if best := selectBestReEnrichTrack(req, tracks); best != nil {
|
|
||||||
t.Fatalf("selected track = %q, want no match", best.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSelectBestReEnrichTrackAllowsExactISRCDespiteMetadataMismatch(t *testing.T) {
|
|
||||||
req := reEnrichRequest{
|
|
||||||
TrackName: "Song Title",
|
|
||||||
ArtistName: "Artist Name",
|
|
||||||
ISRC: "USRC17607839",
|
|
||||||
DurationMs: 999999000,
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks := []ExtTrackMetadata{
|
|
||||||
{
|
|
||||||
ID: "same-isrc",
|
|
||||||
Name: "Different Song",
|
|
||||||
Artists: "Different Artist",
|
|
||||||
DurationMS: 180000,
|
|
||||||
ISRC: "USRC17607839",
|
|
||||||
ProviderID: "deezer",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
best := selectBestReEnrichTrack(req, tracks)
|
|
||||||
if best == nil {
|
|
||||||
t.Fatal("expected exact ISRC candidate to be selected")
|
|
||||||
}
|
|
||||||
if best.ID != "same-isrc" {
|
|
||||||
t.Fatalf("selected track = %q, want exact ISRC candidate", best.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSelectBestReEnrichTrackPlaceholderFallsBackToAlbum(t *testing.T) {
|
|
||||||
req := reEnrichRequest{
|
|
||||||
TrackName: "Unknown Title",
|
|
||||||
ArtistName: "Unknown Artist",
|
|
||||||
AlbumName: "Harry Styles",
|
|
||||||
DurationMs: 180000,
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks := []ExtTrackMetadata{
|
|
||||||
{
|
|
||||||
ID: "album-match",
|
|
||||||
Name: "Sign of the Times",
|
|
||||||
Artists: "Harry Styles",
|
|
||||||
AlbumName: "Harry Styles",
|
|
||||||
DurationMS: 180000,
|
|
||||||
ProviderID: "deezer",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
best := selectBestReEnrichTrack(req, tracks)
|
|
||||||
if best == nil {
|
|
||||||
t.Fatal("expected album-matching candidate to be selected when title/artist are placeholders")
|
|
||||||
}
|
|
||||||
if best.ID != "album-match" {
|
|
||||||
t.Fatalf("selected track = %q, want album-match", best.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
||||||
req := reEnrichRequest{
|
req := reEnrichRequest{
|
||||||
TrackName: "Song",
|
TrackName: "Song",
|
||||||
@@ -508,11 +195,13 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
|||||||
|
|
||||||
metadata := buildReEnrichFFmpegMetadata(&req, "")
|
metadata := buildReEnrichFFmpegMetadata(&req, "")
|
||||||
|
|
||||||
if metadata["TITLE"] != "Song" {
|
// Title and Artist are never written by re-enrich (they are search keys
|
||||||
t.Fatalf("title = %q", metadata["TITLE"])
|
// preserved as-is from the file).
|
||||||
|
if _, exists := metadata["TITLE"]; exists {
|
||||||
|
t.Fatalf("TITLE should not be in metadata: %#v", metadata)
|
||||||
}
|
}
|
||||||
if metadata["ARTIST"] != "Artist" {
|
if _, exists := metadata["ARTIST"]; exists {
|
||||||
t.Fatalf("artist = %q", metadata["ARTIST"])
|
t.Fatalf("ARTIST should not be in metadata: %#v", metadata)
|
||||||
}
|
}
|
||||||
if metadata["ALBUM"] != "Album" {
|
if metadata["ALBUM"] != "Album" {
|
||||||
t.Fatalf("album = %q", metadata["ALBUM"])
|
t.Fatalf("album = %q", metadata["ALBUM"])
|
||||||
@@ -535,75 +224,3 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildReEnrichSearchQuerySkipsPlaceholderArtist(t *testing.T) {
|
|
||||||
req := reEnrichRequest{
|
|
||||||
TrackName: "Sign of the Times",
|
|
||||||
ArtistName: "Unknown Artist",
|
|
||||||
AlbumName: "Harry Styles",
|
|
||||||
}
|
|
||||||
|
|
||||||
query := buildReEnrichSearchQuery(req)
|
|
||||||
if query != "Sign of the Times" {
|
|
||||||
t.Fatalf("query = %q", query)
|
|
||||||
}
|
|
||||||
|
|
||||||
req = reEnrichRequest{
|
|
||||||
TrackName: "Unknown Title",
|
|
||||||
ArtistName: "Unknown Artist",
|
|
||||||
AlbumName: "Harry Styles",
|
|
||||||
}
|
|
||||||
query = buildReEnrichSearchQuery(req)
|
|
||||||
if query != "Harry Styles" {
|
|
||||||
t.Fatalf("fallback album query = %q", query)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) {
|
|
||||||
req := reEnrichRequest{}
|
|
||||||
|
|
||||||
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
|
||||||
Name: "Resolved Song",
|
|
||||||
Artists: "Resolved Artist",
|
|
||||||
TrackNumber: 7,
|
|
||||||
TotalTracks: 12,
|
|
||||||
DiscNumber: 2,
|
|
||||||
TotalDiscs: 3,
|
|
||||||
Composer: "Composer",
|
|
||||||
})
|
|
||||||
|
|
||||||
if req.TrackNumber != 7 || req.TotalTracks != 12 {
|
|
||||||
t.Fatalf("track metadata = %d/%d", req.TrackNumber, req.TotalTracks)
|
|
||||||
}
|
|
||||||
if req.DiscNumber != 2 || req.TotalDiscs != 3 {
|
|
||||||
t.Fatalf("disc metadata = %d/%d", req.DiscNumber, req.TotalDiscs)
|
|
||||||
}
|
|
||||||
if req.TrackName != "Resolved Song" || req.ArtistName != "Resolved Artist" {
|
|
||||||
t.Fatalf("basic tags = %q / %q", req.TrackName, req.ArtistName)
|
|
||||||
}
|
|
||||||
if req.Composer != "Composer" {
|
|
||||||
t.Fatalf("composer = %q", req.Composer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildReEnrichFFmpegMetadataFormatsTotalsAndComposer(t *testing.T) {
|
|
||||||
req := reEnrichRequest{
|
|
||||||
TrackNumber: 7,
|
|
||||||
TotalTracks: 12,
|
|
||||||
DiscNumber: 2,
|
|
||||||
TotalDiscs: 3,
|
|
||||||
Composer: "Composer",
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata := buildReEnrichFFmpegMetadata(&req, "")
|
|
||||||
|
|
||||||
if metadata["TRACKNUMBER"] != "7/12" {
|
|
||||||
t.Fatalf("TRACKNUMBER = %q", metadata["TRACKNUMBER"])
|
|
||||||
}
|
|
||||||
if metadata["DISCNUMBER"] != "2/3" {
|
|
||||||
t.Fatalf("DISCNUMBER = %q", metadata["DISCNUMBER"])
|
|
||||||
}
|
|
||||||
if metadata["COMPOSER"] != "Composer" {
|
|
||||||
t.Fatalf("COMPOSER = %q", metadata["COMPOSER"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,459 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
extensionHealthDefaultTimeout = 4 * time.Second
|
|
||||||
extensionHealthMaxBodyBytes = 64 * 1024
|
|
||||||
extensionHealthDefaultCache = 10 * time.Minute
|
|
||||||
extensionHealthMinCache = 60 * time.Second
|
|
||||||
extensionHealthUnknownCache = 2 * time.Minute
|
|
||||||
)
|
|
||||||
|
|
||||||
type ExtensionHealthResult struct {
|
|
||||||
ExtensionID string `json:"extension_id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
CheckedAt string `json:"checked_at"`
|
|
||||||
Checks []ExtensionHealthCheckResult `json:"checks"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExtensionHealthCheckResult struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Label string `json:"label,omitempty"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Method string `json:"method"`
|
|
||||||
ServiceKey string `json:"service_key,omitempty"`
|
|
||||||
Required bool `json:"required"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
HTTPStatus int `json:"http_status,omitempty"`
|
|
||||||
LatencyMs int64 `json:"latency_ms"`
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
CheckedAt string `json:"checked_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type cachedExtensionHealthResult struct {
|
|
||||||
result ExtensionHealthResult
|
|
||||||
expiresAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
extensionHealthCacheMu sync.Mutex
|
|
||||||
extensionHealthCache = map[string]cachedExtensionHealthResult{}
|
|
||||||
)
|
|
||||||
|
|
||||||
func CheckExtensionHealthJSON(extensionID string) (string, error) {
|
|
||||||
manager := getExtensionManager()
|
|
||||||
ext, err := manager.GetExtension(extensionID)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
result := CheckExtensionHealth(ext)
|
|
||||||
cacheExtensionHealthResult(ext, result)
|
|
||||||
bytes, err := json.Marshal(result)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(bytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
|
|
||||||
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
|
|
||||||
return CheckExtensionHealth(ext)
|
|
||||||
}
|
|
||||||
|
|
||||||
cacheKey := strings.TrimSpace(ext.ID)
|
|
||||||
if cacheKey == "" {
|
|
||||||
return CheckExtensionHealth(ext)
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
extensionHealthCacheMu.Lock()
|
|
||||||
cached, ok := extensionHealthCache[cacheKey]
|
|
||||||
if ok && now.Before(cached.expiresAt) {
|
|
||||||
extensionHealthCacheMu.Unlock()
|
|
||||||
return cached.result
|
|
||||||
}
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
extensionHealthCacheMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
|
||||||
result := ExtensionHealthResult{
|
|
||||||
ExtensionID: "",
|
|
||||||
Status: "unsupported",
|
|
||||||
CheckedAt: now,
|
|
||||||
Checks: []ExtensionHealthCheckResult{},
|
|
||||||
}
|
|
||||||
if ext == nil || ext.Manifest == nil {
|
|
||||||
result.Status = "offline"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
result.ExtensionID = ext.ID
|
|
||||||
checks := ext.Manifest.ServiceHealth
|
|
||||||
if len(checks) == 0 {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Status = "online"
|
|
||||||
for _, check := range checks {
|
|
||||||
checkResult := runExtensionHealthCheck(ext.Manifest, check)
|
|
||||||
result.Checks = append(result.Checks, checkResult)
|
|
||||||
|
|
||||||
switch checkResult.Status {
|
|
||||||
case "offline":
|
|
||||||
if check.Required {
|
|
||||||
result.Status = "offline"
|
|
||||||
} else if result.Status == "online" {
|
|
||||||
result.Status = "degraded"
|
|
||||||
}
|
|
||||||
case "degraded":
|
|
||||||
if result.Status == "online" {
|
|
||||||
result.Status = "degraded"
|
|
||||||
}
|
|
||||||
case "unknown":
|
|
||||||
if result.Status == "online" {
|
|
||||||
result.Status = "unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func extensionHealthCacheTTL(checks []ExtensionHealthCheck) time.Duration {
|
|
||||||
ttl := extensionHealthDefaultCache
|
|
||||||
for _, check := range checks {
|
|
||||||
if check.CacheTTLSeconds <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second
|
|
||||||
if checkTTL < extensionHealthMinCache {
|
|
||||||
checkTTL = extensionHealthMinCache
|
|
||||||
}
|
|
||||||
if checkTTL < ttl {
|
|
||||||
ttl = checkTTL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ttl
|
|
||||||
}
|
|
||||||
|
|
||||||
func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthCheck) ExtensionHealthCheckResult {
|
|
||||||
method := strings.ToUpper(strings.TrimSpace(check.Method))
|
|
||||||
if method == "" {
|
|
||||||
method = http.MethodGet
|
|
||||||
}
|
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
|
||||||
result := ExtensionHealthCheckResult{
|
|
||||||
ID: check.ID,
|
|
||||||
Label: check.Label,
|
|
||||||
URL: check.URL,
|
|
||||||
Method: method,
|
|
||||||
ServiceKey: strings.TrimSpace(check.ServiceKey),
|
|
||||||
Required: check.Required,
|
|
||||||
Status: "unknown",
|
|
||||||
CheckedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := url.Parse(check.URL)
|
|
||||||
if err != nil {
|
|
||||||
result.Status = "offline"
|
|
||||||
result.Error = fmt.Sprintf("invalid health URL: %v", err)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if parsed.Scheme != "https" {
|
|
||||||
result.Status = "offline"
|
|
||||||
result.Error = "health check must use https"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
host := parsed.Hostname()
|
|
||||||
if host == "" {
|
|
||||||
result.Status = "offline"
|
|
||||||
result.Error = "health check URL hostname is required"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if isPrivateIP(host) {
|
|
||||||
result.Status = "offline"
|
|
||||||
result.Error = "private/local health check host is not allowed"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if manifest == nil || !manifest.IsDomainAllowed(host) {
|
|
||||||
result.Status = "offline"
|
|
||||||
result.Error = fmt.Sprintf("health check host '%s' is not in extension network permissions", host)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if method != http.MethodGet && method != http.MethodHead {
|
|
||||||
result.Status = "offline"
|
|
||||||
result.Error = "health check method must be GET or HEAD"
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
timeout := extensionHealthDefaultTimeout
|
|
||||||
if check.TimeoutMs > 0 {
|
|
||||||
timeout = time.Duration(check.TimeoutMs) * time.Millisecond
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, method, check.URL, nil)
|
|
||||||
if err != nil {
|
|
||||||
result.Status = "offline"
|
|
||||||
result.Error = err.Error()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
req.Header.Set("User-Agent", userAgentForURL(parsed))
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
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.Error = err.Error()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
result.HTTPStatus = resp.StatusCode
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
result.Status = "offline"
|
|
||||||
result.Message = resp.Status
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
if method == http.MethodHead {
|
|
||||||
result.Status = "online"
|
|
||||||
result.Message = resp.Status
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, extensionHealthMaxBodyBytes))
|
|
||||||
if err != nil {
|
|
||||||
result.Status = "degraded"
|
|
||||||
result.Error = err.Error()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
status, message := classifyExtensionHealthBody(body, check.ServiceKey)
|
|
||||||
result.Status = status
|
|
||||||
if message == "" {
|
|
||||||
result.Message = resp.Status
|
|
||||||
} else {
|
|
||||||
result.Message = message
|
|
||||||
}
|
|
||||||
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]interface{}
|
|
||||||
if err := json.Unmarshal(body, &payload); err != nil {
|
|
||||||
return "online", ""
|
|
||||||
}
|
|
||||||
|
|
||||||
serviceKey = strings.TrimSpace(serviceKey)
|
|
||||||
if serviceKey != "" {
|
|
||||||
if status, message, ok := classifyExtensionHealthService(payload, serviceKey); ok {
|
|
||||||
return status, message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rawStatus, _ := payload["status"].(string)
|
|
||||||
normalized := strings.ToLower(strings.TrimSpace(rawStatus))
|
|
||||||
switch normalized {
|
|
||||||
case "", "ok", "up", "online", "healthy", "operational", "pass", "passing":
|
|
||||||
return "online", rawStatus
|
|
||||||
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]interface{}, serviceKey string) (string, string, bool) {
|
|
||||||
rawServices, ok := payload["services"]
|
|
||||||
if !ok {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
services, ok := rawServices.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
rawService, ok := services[serviceKey]
|
|
||||||
if !ok {
|
|
||||||
return "unknown", fmt.Sprintf("service '%s' not found", serviceKey), true
|
|
||||||
}
|
|
||||||
service, ok := rawService.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return "unknown", fmt.Sprintf("service '%s' has invalid health payload", serviceKey), true
|
|
||||||
}
|
|
||||||
|
|
||||||
label, _ := service["label"].(string)
|
|
||||||
detail, _ := service["detail"].(string)
|
|
||||||
errText, _ := service["error"].(string)
|
|
||||||
messageParts := []string{}
|
|
||||||
if strings.TrimSpace(label) != "" {
|
|
||||||
messageParts = append(messageParts, strings.TrimSpace(label))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(detail) != "" {
|
|
||||||
messageParts = append(messageParts, strings.TrimSpace(detail))
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(errText) != "" {
|
|
||||||
messageParts = append(messageParts, strings.TrimSpace(errText))
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
|
|
||||||
return "degraded", joinedMessage, true
|
|
||||||
}
|
|
||||||
if statusCode == http.StatusInternalServerError && hasOK && okValue {
|
|
||||||
return "online", joinedMessage, true
|
|
||||||
}
|
|
||||||
if transient || isTransientHealthStatusCode(statusCode) {
|
|
||||||
return "unknown", joinedMessage, true
|
|
||||||
}
|
|
||||||
return "offline", joinedMessage, true
|
|
||||||
}
|
|
||||||
|
|
||||||
if isExtensionHealthAuthRequired(detail) {
|
|
||||||
return "degraded", joinedMessage, true
|
|
||||||
}
|
|
||||||
if transient {
|
|
||||||
return "unknown", joinedMessage, true
|
|
||||||
}
|
|
||||||
if hasOK {
|
|
||||||
if okValue {
|
|
||||||
return "online", joinedMessage, true
|
|
||||||
}
|
|
||||||
return "offline", joinedMessage, true
|
|
||||||
}
|
|
||||||
if !hasStatus {
|
|
||||||
return "unknown", joinedMessage, true
|
|
||||||
}
|
|
||||||
|
|
||||||
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
|
|
||||||
switch statusString {
|
|
||||||
case "ok", "up", "online", "healthy", "operational":
|
|
||||||
return "online", joinedMessage, true
|
|
||||||
case "degraded", "partial", "warning", "warn":
|
|
||||||
return "degraded", joinedMessage, true
|
|
||||||
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
|
||||||
return "offline", joinedMessage, true
|
|
||||||
default:
|
|
||||||
return "unknown", joinedMessage, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isExtensionHealthAuthRequired(detail string) bool {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(detail)) {
|
|
||||||
case "auth_required", "authorization_required", "login_required", "unauthorized":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 interface{}) (int, bool) {
|
|
||||||
switch v := value.(type) {
|
|
||||||
case float64:
|
|
||||||
return int(v), true
|
|
||||||
case int:
|
|
||||||
return v, true
|
|
||||||
case json.Number:
|
|
||||||
n, err := v.Int64()
|
|
||||||
return int(n), err == nil
|
|
||||||
default:
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestExtensionHealthClassificationAndValidation(t *testing.T) {
|
|
||||||
if status, msg := classifyExtensionHealthBody([]byte(`{"status":"degraded"}`), ""); status != "degraded" || msg != "degraded" {
|
|
||||||
t.Fatalf("status/message = %q/%q", status, msg)
|
|
||||||
}
|
|
||||||
if status, _ := classifyExtensionHealthBody([]byte(`not-json`), ""); status != "online" {
|
|
||||||
t.Fatalf("invalid JSON status = %q", status)
|
|
||||||
}
|
|
||||||
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]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 {
|
|
||||||
t.Fatalf("health number = %d/%v", n, ok)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
manifest := &ExtensionManifest{Permissions: ExtensionPermissions{Network: []string{"status.example.com"}}}
|
|
||||||
invalidURL := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "bad", URL: "://bad"})
|
|
||||||
if invalidURL.Status != "offline" {
|
|
||||||
t.Fatalf("invalid URL = %#v", invalidURL)
|
|
||||||
}
|
|
||||||
insecure := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "http", URL: "http://status.example.com"})
|
|
||||||
if insecure.Status != "offline" || !strings.Contains(insecure.Error, "https") {
|
|
||||||
t.Fatalf("insecure = %#v", insecure)
|
|
||||||
}
|
|
||||||
disallowedHost := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "host", URL: "https://other.example.com"})
|
|
||||||
if disallowedHost.Status != "offline" || !strings.Contains(disallowedHost.Error, "permissions") {
|
|
||||||
t.Fatalf("host = %#v", disallowedHost)
|
|
||||||
}
|
|
||||||
badMethod := runExtensionHealthCheck(manifest, ExtensionHealthCheck{ID: "method", URL: "https://status.example.com", Method: "POST"})
|
|
||||||
if badMethod.Status != "offline" || !strings.Contains(badMethod.Error, "method") {
|
|
||||||
t.Fatalf("method = %#v", badMethod)
|
|
||||||
}
|
|
||||||
|
|
||||||
ext := &loadedExtension{
|
|
||||||
ID: "health-ext",
|
|
||||||
Manifest: &ExtensionManifest{
|
|
||||||
ServiceHealth: []ExtensionHealthCheck{
|
|
||||||
{ID: "required", URL: "http://status.example.com", Required: true},
|
|
||||||
{ID: "optional", URL: "http://status.example.com", Required: false},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if result := CheckExtensionHealth(ext); result.Status != "offline" || len(result.Checks) != 2 {
|
|
||||||
t.Fatalf("extension health = %#v", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
if got := upgradeToMaxQuality("https://resources.tidal.com/images/id/320x320.jpg"); !strings.Contains(got, "origin.jpg") {
|
|
||||||
t.Fatalf("tidal cover = %q", got)
|
|
||||||
}
|
|
||||||
if got := upgradeToMaxQuality("https://static.qobuz.com/images/covers/ab/cd/foo_600.jpg"); !strings.Contains(got, "_max.jpg") {
|
|
||||||
t.Fatalf("qobuz cover = %q", got)
|
|
||||||
}
|
|
||||||
if data, err := downloadCoverToMemory("", false); err == nil || data != nil {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
body := `{"id":"1","type":"song","title":"Song","links":[{"type":"tidal","url":"https://tidal.com/browse/track/7"},{"type":"deezer","url":"https://www.deezer.com/track/9"},{"type":"spotify","url":"https://open.spotify.com/track/abc"}]}`
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
|
||||||
Request: req,
|
|
||||||
}, nil
|
|
||||||
})}}
|
|
||||||
availability, err := client.GetAvailabilityFromSpotify("spotify-track")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAvailabilityFromSpotify: %v", err)
|
|
||||||
}
|
|
||||||
if !availability.Tidal || !availability.Deezer || availability.DeezerID != "9" {
|
|
||||||
t.Fatalf("spotify availability = %#v", availability)
|
|
||||||
}
|
|
||||||
deezerAvailability, err := client.GetAvailabilityFromDeezer("9")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAvailabilityFromDeezer: %v", err)
|
|
||||||
}
|
|
||||||
if deezerAvailability.SpotifyID != "abc" || !deezerAvailability.Tidal {
|
|
||||||
t.Fatalf("deezer availability = %#v", deezerAvailability)
|
|
||||||
}
|
|
||||||
|
|
||||||
errorClient := &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
return &http.Response{StatusCode: 429, Body: io.NopCloser(strings.NewReader("")), Request: req}, nil
|
|
||||||
})}}
|
|
||||||
if _, err := errorClient.Search("bad", nil); err == nil {
|
|
||||||
t.Fatal("expected rate limit error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
@@ -44,24 +43,18 @@ func compareVersions(v1, v2 string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func isExtensionPackagePath(filePath string) bool {
|
type LoadedExtension struct {
|
||||||
lowerPath := strings.ToLower(filePath)
|
ID string `json:"id"`
|
||||||
return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx")
|
Manifest *ExtensionManifest `json:"manifest"`
|
||||||
}
|
VM *goja.Runtime `json:"-"`
|
||||||
|
VMMu sync.Mutex `json:"-"`
|
||||||
type loadedExtension struct {
|
runtime *ExtensionRuntime
|
||||||
ID string `json:"id"`
|
initialized bool
|
||||||
Manifest *ExtensionManifest `json:"manifest"`
|
Enabled bool `json:"enabled"`
|
||||||
VM *goja.Runtime `json:"-"`
|
Error string `json:"error,omitempty"`
|
||||||
VMMu sync.Mutex `json:"-"`
|
DataDir string `json:"data_dir"`
|
||||||
runtime *extensionRuntime
|
SourceDir string `json:"source_dir"`
|
||||||
indexProgram *goja.Program
|
IconPath string `json:"icon_path"`
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
||||||
@@ -80,7 +73,7 @@ func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
|||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureRuntimeReadyLocked(ext *loadedExtension, applyStoredSettings bool) error {
|
func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error {
|
||||||
if ext.VM == nil || ext.runtime == nil {
|
if ext.VM == nil || ext.runtime == nil {
|
||||||
if err := initializeVMLocked(ext); err != nil {
|
if err := initializeVMLocked(ext); err != nil {
|
||||||
ext.Error = err.Error()
|
ext.Error = err.Error()
|
||||||
@@ -107,14 +100,14 @@ func ensureRuntimeReadyLocked(ext *loadedExtension, applyStoredSettings bool) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ext *loadedExtension) ensureRuntimeReady() error {
|
func (ext *LoadedExtension) ensureRuntimeReady() error {
|
||||||
ext.VMMu.Lock()
|
ext.VMMu.Lock()
|
||||||
defer ext.VMMu.Unlock()
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
return ensureRuntimeReadyLocked(ext, true)
|
return ensureRuntimeReadyLocked(ext, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||||
ext.VMMu.Lock()
|
ext.VMMu.Lock()
|
||||||
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
|
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
|
||||||
ext.VMMu.Unlock()
|
ext.VMMu.Unlock()
|
||||||
@@ -123,32 +116,28 @@ func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
|||||||
return ext.VM, nil
|
return ext.VM, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type extensionManager struct {
|
type ExtensionManager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
// mutationMu serializes install/upgrade/remove (heavy FS + goja VM
|
extensions map[string]*LoadedExtension
|
||||||
// teardown/reload), which are not safe to run concurrently. Acquired before
|
|
||||||
// m.mu; "*Locked" helpers assume it is held.
|
|
||||||
mutationMu sync.Mutex
|
|
||||||
extensions map[string]*loadedExtension
|
|
||||||
extensionsDir string
|
extensionsDir string
|
||||||
dataDir string
|
dataDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
globalExtManager *extensionManager
|
globalExtManager *ExtensionManager
|
||||||
globalExtManagerOnce sync.Once
|
globalExtManagerOnce sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
func getExtensionManager() *extensionManager {
|
func GetExtensionManager() *ExtensionManager {
|
||||||
globalExtManagerOnce.Do(func() {
|
globalExtManagerOnce.Do(func() {
|
||||||
globalExtManager = &extensionManager{
|
globalExtManager = &ExtensionManager{
|
||||||
extensions: make(map[string]*loadedExtension),
|
extensions: make(map[string]*LoadedExtension),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalExtManager
|
return globalExtManager
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -165,20 +154,14 @@ func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
|
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) {
|
||||||
m.mutationMu.Lock()
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
defer m.mutationMu.Unlock()
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
return m.loadExtensionFromFileLocked(filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
zipReader, err := zip.OpenReader(filePath)
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot open extension file: the file may be corrupted or not a valid extension package")
|
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
||||||
}
|
}
|
||||||
defer zipReader.Close()
|
defer zipReader.Close()
|
||||||
|
|
||||||
@@ -203,16 +186,16 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
|
|||||||
}
|
}
|
||||||
|
|
||||||
if manifestData == nil {
|
if manifestData == nil {
|
||||||
return nil, fmt.Errorf("invalid extension package: manifest.json not found")
|
return nil, fmt.Errorf("Invalid extension package: manifest.json not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hasIndexJS {
|
if !hasIndexJS {
|
||||||
return nil, fmt.Errorf("invalid extension package: index.js not found")
|
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
manifest, err := ParseManifest(manifestData)
|
manifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid extension manifest: %w", err)
|
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
@@ -228,11 +211,11 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
|
|||||||
if exists {
|
if exists {
|
||||||
versionCompare := compareVersions(manifest.Version, existingVersion)
|
versionCompare := compareVersions(manifest.Version, existingVersion)
|
||||||
if versionCompare > 0 {
|
if versionCompare > 0 {
|
||||||
return m.upgradeExtensionLocked(filePath)
|
return m.UpgradeExtension(filePath)
|
||||||
} else if versionCompare == 0 {
|
} else if versionCompare == 0 {
|
||||||
return nil, fmt.Errorf("extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
return nil, fmt.Errorf("Extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
||||||
} else {
|
} else {
|
||||||
return nil, fmt.Errorf("cannot downgrade '%s' from v%s to v%s", existingDisplayName, existingVersion, manifest.Version)
|
return nil, fmt.Errorf("Cannot downgrade '%s' from v%s to v%s", existingDisplayName, existingVersion, manifest.Version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +223,7 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
|
|||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
if _, exists := m.extensions[manifest.Name]; exists {
|
if _, exists := m.extensions[manifest.Name]; exists {
|
||||||
return nil, fmt.Errorf("extension '%s' was installed by another process", manifest.DisplayName)
|
return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
extDir := filepath.Join(m.extensionsDir, manifest.Name)
|
extDir := filepath.Join(m.extensionsDir, manifest.Name)
|
||||||
@@ -289,7 +272,7 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
|
|||||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := &loadedExtension{
|
ext := &LoadedExtension{
|
||||||
ID: manifest.Name,
|
ID: manifest.Name,
|
||||||
Manifest: manifest,
|
Manifest: manifest,
|
||||||
Enabled: false, // New extensions start disabled
|
Enabled: false, // New extensions start disabled
|
||||||
@@ -309,10 +292,9 @@ func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loaded
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func initializeVMLocked(ext *loadedExtension) error {
|
func initializeVMLocked(ext *LoadedExtension) error {
|
||||||
ext.VM = nil
|
ext.VM = nil
|
||||||
ext.runtime = nil
|
ext.runtime = nil
|
||||||
ext.indexProgram = nil
|
|
||||||
ext.initialized = false
|
ext.initialized = false
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
ext.VM = vm
|
ext.VM = vm
|
||||||
@@ -322,13 +304,8 @@ func initializeVMLocked(ext *loadedExtension) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read index.js: %w", err)
|
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)
|
runtime := NewExtensionRuntime(ext)
|
||||||
ext.runtime = runtime
|
ext.runtime = runtime
|
||||||
runtime.RegisterAPIs(vm)
|
runtime.RegisterAPIs(vm)
|
||||||
runtime.RegisterGoBackendAPIs(vm)
|
runtime.RegisterGoBackendAPIs(vm)
|
||||||
@@ -353,7 +330,7 @@ func initializeVMLocked(ext *loadedExtension) error {
|
|||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
_, err = vm.RunProgram(indexProgram)
|
_, err = vm.RunString(string(jsCode))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to execute extension code: %w", err)
|
return fmt.Errorf("failed to execute extension code: %w", err)
|
||||||
}
|
}
|
||||||
@@ -365,97 +342,23 @@ func initializeVMLocked(ext *loadedExtension) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
|
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime := &extensionRuntime{
|
|
||||||
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
|
|
||||||
} else {
|
|
||||||
jar, _ := newSimpleCookieJar()
|
|
||||||
runtime.cookieJar = jar
|
|
||||||
}
|
|
||||||
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second), true)
|
|
||||||
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout, false)
|
|
||||||
runtime.RegisterAPIs(vm)
|
|
||||||
runtime.RegisterGoBackendAPIs(vm)
|
|
||||||
|
|
||||||
console := vm.NewObject()
|
|
||||||
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
|
||||||
args := make([]interface{}, len(call.Arguments))
|
|
||||||
for i, arg := range call.Arguments {
|
|
||||||
args[i] = arg.Export()
|
|
||||||
}
|
|
||||||
GoLog("[Extension:%s] %v\n", ext.ID, args)
|
|
||||||
return goja.Undefined()
|
|
||||||
})
|
|
||||||
vm.Set("console", console)
|
|
||||||
|
|
||||||
var registeredExtension goja.Value
|
|
||||||
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
|
|
||||||
if len(call.Arguments) > 0 {
|
|
||||||
registeredExtension = call.Arguments[0]
|
|
||||||
vm.Set("extension", call.Arguments[0])
|
|
||||||
}
|
|
||||||
return goja.Undefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
if _, err := vm.RunProgram(indexProgram); err != nil {
|
|
||||||
runtime.closeStorageFlusher()
|
|
||||||
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if registeredExtension == nil || goja.IsUndefined(registeredExtension) {
|
|
||||||
runtime.closeStorageFlusher()
|
|
||||||
return nil, nil, fmt.Errorf("extension did not call registerExtension()")
|
|
||||||
}
|
|
||||||
|
|
||||||
settings := getExtensionInitSettings(ext.ID)
|
|
||||||
if len(settings) > 0 {
|
|
||||||
if err := initializeExtensionRuntimeWithSettings(vm, ext.ID, settings); err != nil {
|
|
||||||
runtime.closeStorageFlusher()
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return vm, runtime, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
|
|
||||||
ext.VMMu.Lock()
|
ext.VMMu.Lock()
|
||||||
defer ext.VMMu.Unlock()
|
defer ext.VMMu.Unlock()
|
||||||
return initializeVMLocked(ext)
|
return initializeVMLocked(ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func initializeExtensionRuntimeWithSettings(
|
func initializeExtensionWithSettingsLocked(
|
||||||
vm *goja.Runtime,
|
ext *LoadedExtension,
|
||||||
extensionID string,
|
|
||||||
settings map[string]interface{},
|
settings map[string]interface{},
|
||||||
) error {
|
) error {
|
||||||
|
if ext.VM == nil {
|
||||||
|
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||||
|
}
|
||||||
|
|
||||||
settingsJSON, err := json.Marshal(settings)
|
settingsJSON, err := json.Marshal(settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to save settings")
|
return fmt.Errorf("Failed to save settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
@@ -473,9 +376,11 @@ func initializeExtensionRuntimeWithSettings(
|
|||||||
})()
|
})()
|
||||||
`, string(settingsJSON))
|
`, string(settingsJSON))
|
||||||
|
|
||||||
result, err := vm.RunString(script)
|
result, err := ext.VM.RunString(script)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
|
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
||||||
|
ext.Enabled = false
|
||||||
|
GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,87 +392,61 @@ func initializeExtensionRuntimeWithSettings(
|
|||||||
if e, ok := resultMap["error"].(string); ok {
|
if e, ok := resultMap["error"].(string); ok {
|
||||||
errMsg = e
|
errMsg = e
|
||||||
}
|
}
|
||||||
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
|
ext.Error = errMsg
|
||||||
|
ext.Enabled = false
|
||||||
|
GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg)
|
||||||
return fmt.Errorf("initialize failed: %s", errMsg)
|
return fmt.Errorf("initialize failed: %s", errMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func initializeExtensionWithSettingsLocked(
|
|
||||||
ext *loadedExtension,
|
|
||||||
settings map[string]interface{},
|
|
||||||
) error {
|
|
||||||
if ext.VM == nil {
|
|
||||||
return fmt.Errorf("extension failed to load: please reinstall the extension")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := initializeExtensionRuntimeWithSettings(ext.VM, ext.ID, settings); err != nil {
|
|
||||||
ext.Error = err.Error()
|
|
||||||
ext.Enabled = false
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ext.initialized = true
|
ext.initialized = true
|
||||||
GoLog("[Extension] Initialized %s\n", ext.ID)
|
GoLog("[Extension] Initialized %s\n", ext.ID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runCleanupLocked(ext *loadedExtension) error {
|
func runCleanupLocked(ext *LoadedExtension) error {
|
||||||
if ext.VM != nil {
|
if ext.VM != nil {
|
||||||
if err := runCleanupOnVM(ext.VM); err != nil {
|
script := `
|
||||||
|
(function() {
|
||||||
|
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||||
|
try {
|
||||||
|
extension.cleanup();
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.toString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, message: 'no cleanup function' };
|
||||||
|
})()
|
||||||
|
`
|
||||||
|
|
||||||
|
result, err := ext.VM.RunString(script)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if ext.VM.Get("extension") != nil {
|
|
||||||
|
if result != nil && !goja.IsUndefined(result) {
|
||||||
|
exported := result.Export()
|
||||||
|
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 {
|
||||||
|
errMsg = e
|
||||||
|
}
|
||||||
|
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
|
||||||
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
|
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runCleanupOnVM(vm *goja.Runtime) error {
|
func teardownVMLocked(ext *LoadedExtension) {
|
||||||
if vm == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
script := `
|
|
||||||
(function() {
|
|
||||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
|
||||||
try {
|
|
||||||
extension.cleanup();
|
|
||||||
return { success: true };
|
|
||||||
} catch (e) {
|
|
||||||
return { success: false, error: e.toString() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { success: true, message: 'no cleanup function' };
|
|
||||||
})()
|
|
||||||
`
|
|
||||||
|
|
||||||
result, err := vm.RunString(script)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if result != nil && !goja.IsUndefined(result) {
|
|
||||||
exported := result.Export()
|
|
||||||
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 {
|
|
||||||
errMsg = e
|
|
||||||
}
|
|
||||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func teardownVMLocked(ext *loadedExtension) {
|
|
||||||
if err := runCleanupLocked(ext); err != nil {
|
if err := runCleanupLocked(ext); err != nil {
|
||||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
||||||
}
|
}
|
||||||
@@ -582,7 +461,7 @@ func teardownVMLocked(ext *loadedExtension) {
|
|||||||
ext.initialized = false
|
ext.initialized = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateExtensionLoad(ext *loadedExtension) error {
|
func validateExtensionLoad(ext *LoadedExtension) error {
|
||||||
ext.VMMu.Lock()
|
ext.VMMu.Lock()
|
||||||
defer ext.VMMu.Unlock()
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
@@ -593,13 +472,13 @@ func validateExtensionLoad(ext *loadedExtension) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) UnloadExtension(extensionID string) error {
|
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
ext, exists := m.extensions[extensionID]
|
ext, exists := m.extensions[extensionID]
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
ext.VMMu.Lock()
|
ext.VMMu.Lock()
|
||||||
@@ -612,35 +491,35 @@ func (m *extensionManager) UnloadExtension(extensionID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) GetExtension(extensionID string) (*loadedExtension, error) {
|
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
ext, exists := m.extensions[extensionID]
|
ext, exists := m.extensions[extensionID]
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, fmt.Errorf("extension not found")
|
return nil, fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) GetAllExtensions() []*loadedExtension {
|
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
result := make([]*loadedExtension, 0, len(m.extensions))
|
result := make([]*LoadedExtension, 0, len(m.extensions))
|
||||||
for _, ext := range m.extensions {
|
for _, ext := range m.extensions {
|
||||||
result = append(result, ext)
|
result = append(result, ext)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
ext, exists := m.extensions[extensionID]
|
ext, exists := m.extensions[extensionID]
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if enabled {
|
if enabled {
|
||||||
@@ -668,7 +547,7 @@ func (m *extensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
||||||
var loaded []string
|
var loaded []string
|
||||||
var errors []error
|
var errors []error
|
||||||
|
|
||||||
@@ -692,7 +571,7 @@ func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
|||||||
loaded = append(loaded, ext.ID)
|
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()))
|
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
||||||
@@ -706,7 +585,7 @@ func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
|||||||
return loaded, errors
|
return loaded, errors
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedExtension, error) {
|
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -718,12 +597,12 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx
|
|||||||
|
|
||||||
manifest, err := ParseManifest(manifestData)
|
manifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid extension manifest: %w", err)
|
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
indexPath := filepath.Join(dirPath, "index.js")
|
indexPath := filepath.Join(dirPath, "index.js")
|
||||||
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
|
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
|
||||||
return nil, fmt.Errorf("extension is missing index.js file")
|
return nil, fmt.Errorf("Extension is missing index.js file")
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing, exists := m.extensions[manifest.Name]; exists {
|
if existing, exists := m.extensions[manifest.Name]; exists {
|
||||||
@@ -736,7 +615,7 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx
|
|||||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := &loadedExtension{
|
ext := &LoadedExtension{
|
||||||
ID: manifest.Name,
|
ID: manifest.Name,
|
||||||
Manifest: manifest,
|
Manifest: manifest,
|
||||||
Enabled: false, // Will be restored from settings store
|
Enabled: false, // Will be restored from settings store
|
||||||
@@ -764,10 +643,7 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx
|
|||||||
return ext, nil
|
return ext, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) RemoveExtension(extensionID string) error {
|
func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
||||||
m.mutationMu.Lock()
|
|
||||||
defer m.mutationMu.Unlock()
|
|
||||||
|
|
||||||
ext, err := m.GetExtension(extensionID)
|
ext, err := m.GetExtension(extensionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -787,20 +663,14 @@ func (m *extensionManager) RemoveExtension(extensionID string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only allows upgrades (new version > current version), not downgrades
|
// Only allows upgrades (new version > current version), not downgrades
|
||||||
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
|
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
|
||||||
m.mutationMu.Lock()
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
defer m.mutationMu.Unlock()
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
return m.upgradeExtensionLocked(filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
zipReader, err := zip.OpenReader(filePath)
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot open extension file: the file may be corrupted or not a valid extension package")
|
return nil, fmt.Errorf("Cannot open extension file. The file may be corrupted or not a valid extension package")
|
||||||
}
|
}
|
||||||
defer zipReader.Close()
|
defer zipReader.Close()
|
||||||
|
|
||||||
@@ -825,16 +695,16 @@ func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExten
|
|||||||
}
|
}
|
||||||
|
|
||||||
if manifestData == nil {
|
if manifestData == nil {
|
||||||
return nil, fmt.Errorf("invalid extension package: manifest.json not found")
|
return nil, fmt.Errorf("Invalid extension package: manifest.json not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hasIndexJS {
|
if !hasIndexJS {
|
||||||
return nil, fmt.Errorf("invalid extension package: index.js not found")
|
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
newManifest, err := ParseManifest(manifestData)
|
newManifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid extension manifest: %w", err)
|
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
@@ -842,15 +712,15 @@ func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExten
|
|||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, fmt.Errorf("extension '%s' is not installed; use install instead of upgrade", newManifest.DisplayName)
|
return nil, fmt.Errorf("Extension '%s' is not installed. Use install instead of upgrade.", newManifest.DisplayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version)
|
versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version)
|
||||||
if versionCompare < 0 {
|
if versionCompare < 0 {
|
||||||
return nil, fmt.Errorf("cannot downgrade extension: current version: %s, new version: %s", existing.Manifest.Version, newManifest.Version)
|
return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version)
|
||||||
}
|
}
|
||||||
if versionCompare == 0 {
|
if versionCompare == 0 {
|
||||||
return nil, fmt.Errorf("extension is already at version %s", existing.Manifest.Version)
|
return nil, fmt.Errorf("Extension is already at version %s", existing.Manifest.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version)
|
GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version)
|
||||||
@@ -907,7 +777,7 @@ func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExten
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := &loadedExtension{
|
ext := &LoadedExtension{
|
||||||
ID: newManifest.Name,
|
ID: newManifest.Name,
|
||||||
Manifest: newManifest,
|
Manifest: newManifest,
|
||||||
Enabled: wasEnabled, // Preserve enabled state from before upgrade
|
Enabled: wasEnabled, // Preserve enabled state from before upgrade
|
||||||
@@ -942,15 +812,15 @@ type ExtensionUpgradeInfo struct {
|
|||||||
IsInstalled bool `json:"is_installed"`
|
IsInstalled bool `json:"is_installed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||||
if !isExtensionPackagePath(filePath) {
|
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
|
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||||
}
|
}
|
||||||
|
|
||||||
zipReader, err := zip.OpenReader(filePath)
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot open extension file")
|
return nil, fmt.Errorf("Cannot open extension file")
|
||||||
}
|
}
|
||||||
defer zipReader.Close()
|
defer zipReader.Close()
|
||||||
|
|
||||||
@@ -977,7 +847,7 @@ func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
|
|
||||||
newManifest, err := ParseManifest(manifestData)
|
newManifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid manifest: %w", err)
|
return nil, fmt.Errorf("Invalid manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
@@ -1001,7 +871,7 @@ func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
||||||
info, err := m.checkExtensionUpgradeInternal(filePath)
|
info, err := m.checkExtensionUpgradeInternal(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -1015,7 +885,7 @@ func (m *extensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||||
extensions := m.GetAllExtensions()
|
extensions := m.GetAllExtensions()
|
||||||
|
|
||||||
type ExtensionInfo struct {
|
type ExtensionInfo struct {
|
||||||
@@ -1023,6 +893,7 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
|
Author string `json:"author"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Homepage string `json:"homepage,omitempty"`
|
Homepage string `json:"homepage,omitempty"`
|
||||||
IconPath string `json:"icon_path,omitempty"`
|
IconPath string `json:"icon_path,omitempty"`
|
||||||
@@ -1038,11 +909,9 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
HasLyricsProvider bool `json:"has_lyrics_provider"`
|
HasLyricsProvider bool `json:"has_lyrics_provider"`
|
||||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
||||||
SkipLyrics bool `json:"skip_lyrics"`
|
SkipLyrics bool `json:"skip_lyrics"`
|
||||||
StopProviderFallback bool `json:"stop_provider_fallback"`
|
|
||||||
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
||||||
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
||||||
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
||||||
ServiceHealth []ExtensionHealthCheck `json:"service_health,omitempty"`
|
|
||||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1082,6 +951,7 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
Name: ext.Manifest.Name,
|
Name: ext.Manifest.Name,
|
||||||
DisplayName: ext.Manifest.DisplayName,
|
DisplayName: ext.Manifest.DisplayName,
|
||||||
Version: ext.Manifest.Version,
|
Version: ext.Manifest.Version,
|
||||||
|
Author: ext.Manifest.Author,
|
||||||
Description: ext.Manifest.Description,
|
Description: ext.Manifest.Description,
|
||||||
Homepage: ext.Manifest.Homepage,
|
Homepage: ext.Manifest.Homepage,
|
||||||
IconPath: iconPath,
|
IconPath: iconPath,
|
||||||
@@ -1097,11 +967,9 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
|
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
|
||||||
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
||||||
SkipLyrics: ext.Manifest.SkipLyrics,
|
SkipLyrics: ext.Manifest.SkipLyrics,
|
||||||
StopProviderFallback: ext.Manifest.StopsProviderFallback(),
|
|
||||||
SearchBehavior: ext.Manifest.SearchBehavior,
|
SearchBehavior: ext.Manifest.SearchBehavior,
|
||||||
TrackMatching: ext.Manifest.TrackMatching,
|
TrackMatching: ext.Manifest.TrackMatching,
|
||||||
PostProcessing: ext.Manifest.PostProcessing,
|
PostProcessing: ext.Manifest.PostProcessing,
|
||||||
ServiceHealth: ext.Manifest.ServiceHealth,
|
|
||||||
Capabilities: ext.Manifest.Capabilities,
|
Capabilities: ext.Manifest.Capabilities,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1114,13 +982,13 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
ext, exists := m.extensions[extensionID]
|
ext, exists := m.extensions[extensionID]
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
ext.VMMu.Lock()
|
ext.VMMu.Lock()
|
||||||
@@ -1132,13 +1000,13 @@ func (m *extensionManager) InitializeExtension(extensionID string, settings map[
|
|||||||
return initializeExtensionWithSettingsLocked(ext, settings)
|
return initializeExtensionWithSettingsLocked(ext, settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) CleanupExtension(extensionID string) error {
|
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
ext, exists := m.extensions[extensionID]
|
ext, exists := m.extensions[extensionID]
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("extension not found")
|
return fmt.Errorf("Extension not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext.VM == nil {
|
if ext.VM == nil {
|
||||||
@@ -1154,7 +1022,7 @@ func (m *extensionManager) CleanupExtension(extensionID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) UnloadAllExtensions() {
|
func (m *ExtensionManager) UnloadAllExtensions() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
extensionIDs := make([]string, 0, len(m.extensions))
|
extensionIDs := make([]string, 0, len(m.extensions))
|
||||||
for id := range m.extensions {
|
for id := range m.extensions {
|
||||||
@@ -1169,7 +1037,7 @@ func (m *extensionManager) UnloadAllExtensions() {
|
|||||||
GoLog("[Extension] All extensions unloaded\n")
|
GoLog("[Extension] All extensions unloaded\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
@@ -1187,45 +1055,23 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
|
|||||||
}
|
}
|
||||||
defer ext.VMMu.Unlock()
|
defer ext.VMMu.Unlock()
|
||||||
|
|
||||||
// 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(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
var actionName = %s;
|
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||||
function runAction(fn) {
|
try {
|
||||||
try {
|
var result = extension.%s();
|
||||||
var result = fn();
|
if (result && typeof result.then === 'function') {
|
||||||
if (result && typeof result.then === 'function') {
|
// Handle promise - return pending status
|
||||||
return { success: true, pending: true, message: 'Action started' };
|
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) {
|
|
||||||
isArr = Array.isArray(result);
|
|
||||||
}
|
|
||||||
if (!isArr) {
|
|
||||||
var out = { success: true };
|
|
||||||
for (var k in result) {
|
|
||||||
out[k] = result[k];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { success: true, result: result };
|
return { success: true, result: result };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: e.toString() };
|
return { success: false, error: e.toString() };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (typeof extension !== 'undefined' && extension && typeof extension[actionName] === 'function') {
|
}
|
||||||
return runAction(function() { return extension[actionName](); });
|
return { success: false, error: 'Action function not found: %s' };
|
||||||
}
|
})()
|
||||||
if (actionName === 'completeGrant' && typeof session !== 'undefined' && session && typeof session.completeGrant === 'function') {
|
`, actionName, actionName, actionName)
|
||||||
return runAction(function() { return session.completeGrant(); });
|
|
||||||
}
|
|
||||||
return { success: false, error: 'Action function not found: ' + actionName };
|
|
||||||
})()
|
|
||||||
`, actionNameLiteral)
|
|
||||||
|
|
||||||
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
|
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestExtensionManagerPackageLifecycle(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
extensionsDir := filepath.Join(dir, "extensions")
|
|
||||||
dataDir := filepath.Join(dir, "data")
|
|
||||||
manager := &extensionManager{extensions: map[string]*loadedExtension{}}
|
|
||||||
if err := manager.SetDirectories(extensionsDir, dataDir); err != nil {
|
|
||||||
t.Fatalf("SetDirectories: %v", err)
|
|
||||||
}
|
|
||||||
if err := GetExtensionSettingsStore().SetDataDir(dataDir); err != nil {
|
|
||||||
t.Fatalf("settings data dir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
js := `
|
|
||||||
var cleaned = false;
|
|
||||||
registerExtension({
|
|
||||||
initialize: function(settings) { this.settings = settings || {}; },
|
|
||||||
cleanup: function() { cleaned = true; },
|
|
||||||
doAction: function() { return { message: "done", setting_updates: { quality: "lossless" } }; },
|
|
||||||
getHomeFeed: function() { return [{ id: "home", title: "Home" }]; },
|
|
||||||
getBrowseCategories: function() { return [{ id: "cat", title: "Category" }]; },
|
|
||||||
searchTracks: function() { return { tracks: [], total: 0 }; },
|
|
||||||
fetchLyrics: function() { return { syncType: "UNSYNCED", lines: [{ words: "hello" }] }; },
|
|
||||||
getDownloadUrl: function() { return { url: "https://example.test/a.flac" }; }
|
|
||||||
});
|
|
||||||
`
|
|
||||||
pkgV1 := filepath.Join(dir, "manager-ext-v1.spotiflac-ext")
|
|
||||||
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)
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
if _, err := manager.LoadExtensionFromFile(filepath.Join(dir, "bad.txt")); err == nil {
|
|
||||||
t.Fatal("expected bad extension suffix error")
|
|
||||||
}
|
|
||||||
if _, err := manager.LoadExtensionFromFile(filepath.Join(dir, "missing.spotiflac-ext")); err == nil {
|
|
||||||
t.Fatal("expected invalid package error")
|
|
||||||
}
|
|
||||||
|
|
||||||
ext, err := manager.LoadExtensionFromFile(pkgV1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadExtensionFromFile: %v", err)
|
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
installedJSON, err := manager.GetInstalledExtensionsJSON()
|
|
||||||
if err != nil || !strings.Contains(installedJSON, "manager-ext") || !strings.Contains(installedJSON, "icon_path") {
|
|
||||||
t.Fatalf("GetInstalledExtensionsJSON = %q/%v", installedJSON, err)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := GetExtensionSettingsStore().Set("manager-ext", "quality", "lossless"); err != nil {
|
|
||||||
t.Fatalf("settings Set: %v", err)
|
|
||||||
}
|
|
||||||
if err := manager.SetExtensionEnabled("manager-ext", true); err != nil {
|
|
||||||
t.Fatalf("enable extension: %v", err)
|
|
||||||
}
|
|
||||||
if !ext.Enabled || ext.VM == nil || !ext.initialized {
|
|
||||||
t.Fatalf("enabled extension = %#v", ext)
|
|
||||||
}
|
|
||||||
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")
|
|
||||||
if err != nil || action["success"] != true || action["message"] != "done" {
|
|
||||||
t.Fatalf("InvokeAction = %#v/%v", action, err)
|
|
||||||
}
|
|
||||||
if err := manager.CleanupExtension("manager-ext"); err != nil {
|
|
||||||
t.Fatalf("CleanupExtension: %v", err)
|
|
||||||
}
|
|
||||||
if err := manager.SetExtensionEnabled("manager-ext", false); err != nil {
|
|
||||||
t.Fatalf("disable extension: %v", err)
|
|
||||||
}
|
|
||||||
if ext.VM != nil || ext.initialized {
|
|
||||||
t.Fatalf("expected VM teardown, got %#v", ext)
|
|
||||||
}
|
|
||||||
if _, err := manager.InvokeAction("manager-ext", "doAction"); err == nil {
|
|
||||||
t.Fatal("expected disabled action error")
|
|
||||||
}
|
|
||||||
|
|
||||||
upgradeJSON, err := manager.CheckExtensionUpgradeJSON(pkgV2)
|
|
||||||
if err != nil || !strings.Contains(upgradeJSON, `"can_upgrade":true`) {
|
|
||||||
t.Fatalf("CheckExtensionUpgradeJSON = %q/%v", upgradeJSON, err)
|
|
||||||
}
|
|
||||||
upgraded, err := manager.UpgradeExtension(pkgV2)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("UpgradeExtension: %v", err)
|
|
||||||
}
|
|
||||||
if upgraded.Manifest.Version != "1.1.0" {
|
|
||||||
t.Fatalf("upgraded = %#v", upgraded.Manifest)
|
|
||||||
}
|
|
||||||
if _, err := manager.UpgradeExtension(pkgV1); err == nil {
|
|
||||||
t.Fatal("expected downgrade error")
|
|
||||||
}
|
|
||||||
if err := manager.RemoveExtension("manager-ext"); err != nil {
|
|
||||||
t.Fatalf("RemoveExtension: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := manager.GetExtension("manager-ext"); err == nil {
|
|
||||||
t.Fatal("expected removed extension missing")
|
|
||||||
}
|
|
||||||
|
|
||||||
dirExt := filepath.Join(extensionsDir, "dir-ext")
|
|
||||||
if err := os.MkdirAll(dirExt, 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
manifest := `{"name":"dir-ext","displayName":"dir-ext","version":"1.0.0","description":"Directory extension","type":["metadata_provider"],"permissions":{}}`
|
|
||||||
if err := os.WriteFile(filepath.Join(dirExt, "manifest.json"), []byte(manifest), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(dirExt, "index.js"), []byte(`registerExtension({searchTracks:function(){return {tracks:[], total:0};}});`), 0600); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
loaded, loadErrs := manager.LoadExtensionsFromDirectory(extensionsDir)
|
|
||||||
if len(loadErrs) != 0 || len(loaded) != 1 || loaded[0] != "dir-ext" {
|
|
||||||
t.Fatalf("LoadExtensionsFromDirectory = %#v/%#v", loaded, loadErrs)
|
|
||||||
}
|
|
||||||
manager.UnloadAllExtensions()
|
|
||||||
if len(manager.GetAllExtensions()) != 0 {
|
|
||||||
t.Fatal("expected all extensions unloaded")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,10 +25,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ExtensionPermissions struct {
|
type ExtensionPermissions struct {
|
||||||
Network []string `json:"network"`
|
Network []string `json:"network"`
|
||||||
Storage bool `json:"storage"`
|
Storage bool `json:"storage"`
|
||||||
File bool `json:"file"`
|
File bool `json:"file"`
|
||||||
AllowHTTP bool `json:"allowHttp,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionSetting struct {
|
type ExtensionSetting struct {
|
||||||
@@ -103,60 +101,27 @@ type PostProcessingConfig struct {
|
|||||||
Hooks []PostProcessingHook `json:"hooks,omitempty"`
|
Hooks []PostProcessingHook `json:"hooks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExtensionHealthCheck struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Label string `json:"label,omitempty"`
|
|
||||||
URL string `json:"url"`
|
|
||||||
Method string `json:"method,omitempty"`
|
|
||||||
ServiceKey string `json:"serviceKey,omitempty"`
|
|
||||||
TimeoutMs int `json:"timeoutMs,omitempty"`
|
|
||||||
CacheTTLSeconds int `json:"cacheTtlSeconds,omitempty"`
|
|
||||||
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 {
|
type ExtensionManifest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Description string `json:"description"`
|
Author string `json:"author"`
|
||||||
Homepage string `json:"homepage,omitempty"`
|
Description string `json:"description"`
|
||||||
Icon string `json:"icon,omitempty"`
|
Homepage string `json:"homepage,omitempty"`
|
||||||
Types []ExtensionType `json:"type"`
|
Icon string `json:"icon,omitempty"`
|
||||||
Permissions ExtensionPermissions `json:"permissions"`
|
Types []ExtensionType `json:"type"`
|
||||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
Permissions ExtensionPermissions `json:"permissions"`
|
||||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||||
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||||
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
|
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
||||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||||
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
|
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||||
SignedSession *SignedSessionConfig `json:"signedSession,omitempty"`
|
|
||||||
RequiredRuntimeFeatures []string `json:"requiredRuntimeFeatures,omitempty"`
|
|
||||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ManifestValidationError struct {
|
type ManifestValidationError struct {
|
||||||
@@ -190,6 +155,10 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
return &ManifestValidationError{Field: "version", Message: "version is required"}
|
return &ManifestValidationError{Field: "version", Message: "version is required"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(m.Author) == "" {
|
||||||
|
return &ManifestValidationError{Field: "author", Message: "author is required"}
|
||||||
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(m.Description) == "" {
|
if strings.TrimSpace(m.Description) == "" {
|
||||||
return &ManifestValidationError{Field: "description", Message: "description is required"}
|
return &ManifestValidationError{Field: "description", Message: "description is required"}
|
||||||
}
|
}
|
||||||
@@ -222,6 +191,7 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Select type requires options
|
||||||
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
||||||
return &ManifestValidationError{
|
return &ManifestValidationError{
|
||||||
Field: fmt.Sprintf("settings[%d].options", i),
|
Field: fmt.Sprintf("settings[%d].options", i),
|
||||||
@@ -237,48 +207,6 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, check := range m.ServiceHealth {
|
|
||||||
if strings.TrimSpace(check.ID) == "" {
|
|
||||||
return &ManifestValidationError{
|
|
||||||
Field: fmt.Sprintf("serviceHealth[%d].id", i),
|
|
||||||
Message: "health check id is required",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(check.URL) == "" {
|
|
||||||
return &ManifestValidationError{
|
|
||||||
Field: fmt.Sprintf("serviceHealth[%d].url", i),
|
|
||||||
Message: "health check url is required",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
method := strings.ToUpper(strings.TrimSpace(check.Method))
|
|
||||||
if method != "" && method != "GET" && method != "HEAD" {
|
|
||||||
return &ManifestValidationError{
|
|
||||||
Field: fmt.Sprintf("serviceHealth[%d].method", i),
|
|
||||||
Message: "health check method must be GET or HEAD",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.SignedSession != nil {
|
|
||||||
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"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,13 +231,6 @@ func (m *ExtensionManifest) IsLyricsProvider() bool {
|
|||||||
return m.HasType(ExtensionTypeLyricsProvider)
|
return m.HasType(ExtensionTypeLyricsProvider)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *ExtensionManifest) StopsProviderFallback() bool {
|
|
||||||
if m == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return m.StopProviderFallback || m.SkipBuiltInFallback
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
func (m *ExtensionManifest) IsDomainAllowed(domain string) bool {
|
||||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||||
for _, allowed := range m.Permissions.Network {
|
for _, allowed := range m.Permissions.Network {
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
|
||||||
)
|
|
||||||
|
|
||||||
type extensionCallPerf struct {
|
|
||||||
extensionID string
|
|
||||||
operation string
|
|
||||||
startedAt time.Time
|
|
||||||
initMs float64
|
|
||||||
jsMs float64
|
|
||||||
parseMs float64
|
|
||||||
items int
|
|
||||||
payloadBytes int
|
|
||||||
}
|
|
||||||
|
|
||||||
func newExtensionCallPerf(extensionID, operation string) *extensionCallPerf {
|
|
||||||
if !GetLogBuffer().IsLoggingEnabled() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &extensionCallPerf{
|
|
||||||
extensionID: extensionID,
|
|
||||||
operation: operation,
|
|
||||||
startedAt: time.Now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func extensionDurationMs(duration time.Duration) float64 {
|
|
||||||
return float64(duration.Microseconds()) / 1000.0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *extensionCallPerf) recordInit(duration time.Duration) {
|
|
||||||
if p == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p.initMs += extensionDurationMs(duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *extensionCallPerf) recordJS(duration time.Duration) {
|
|
||||||
if p == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p.jsMs += extensionDurationMs(duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *extensionCallPerf) recordParse(duration time.Duration) {
|
|
||||||
if p == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p.parseMs += extensionDurationMs(duration)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *extensionCallPerf) recordPayload(value goja.Value) {
|
|
||||||
if p == nil || gojaValueIsEmpty(value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if payload, err := json.Marshal(value); err == nil {
|
|
||||||
p.payloadBytes = len(payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *extensionCallPerf) setPayloadBytes(payloadBytes int) {
|
|
||||||
if p == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p.payloadBytes = payloadBytes
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *extensionCallPerf) setItems(items int) {
|
|
||||||
if p == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p.items = items
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *extensionCallPerf) finish() {
|
|
||||||
if p == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
LogDebug(
|
|
||||||
"ExtensionPerf",
|
|
||||||
"extension=%s op=%s totalMs=%.1f initMs=%.1f jsMs=%.1f parseMs=%.1f items=%d payloadBytes=%d",
|
|
||||||
p.extensionID,
|
|
||||||
p.operation,
|
|
||||||
extensionDurationMs(time.Since(p.startedAt)),
|
|
||||||
p.initMs,
|
|
||||||
p.jsMs,
|
|
||||||
p.parseMs,
|
|
||||||
p.items,
|
|
||||||
p.payloadBytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func countExtensionTopLevelItems(vm *goja.Runtime, value goja.Value) int {
|
|
||||||
if gojaValueIsEmpty(value) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if length, err := gojaArrayLength(value, vm); err == nil && length > 0 {
|
|
||||||
return length
|
|
||||||
}
|
|
||||||
|
|
||||||
obj := value.ToObject(vm)
|
|
||||||
for _, key := range []string{"items", "tracks", "sections", "albums", "artists", "playlists", "results"} {
|
|
||||||
child := obj.Get(key)
|
|
||||||
if gojaValueIsEmpty(child) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if length, err := gojaArrayLength(child, vm); err == nil && length > 0 {
|
|
||||||
return length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestExtensionProviderWrapperFullSurface(t *testing.T) {
|
|
||||||
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider, ExtensionTypeDownloadProvider, ExtensionTypeLyricsProvider)
|
|
||||||
provider := newExtensionProviderWrapper(ext)
|
|
||||||
|
|
||||||
search, err := provider.SearchTracks("query", 5)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SearchTracks: %v", err)
|
|
||||||
}
|
|
||||||
if search.Total != 1 || search.Tracks[0].ProviderID != ext.ID || search.Tracks[0].ExternalLinks["tidal"] == "" {
|
|
||||||
t.Fatalf("search = %#v", search)
|
|
||||||
}
|
|
||||||
|
|
||||||
track, err := provider.GetTrack("track-1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetTrack: %v", err)
|
|
||||||
}
|
|
||||||
if track.Name != "Track track-1" || track.ProviderID != ext.ID || track.AudioQuality == "" {
|
|
||||||
t.Fatalf("track = %#v", track)
|
|
||||||
}
|
|
||||||
|
|
||||||
album, err := provider.GetAlbum("album-1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAlbum: %v", err)
|
|
||||||
}
|
|
||||||
if album.ProviderID != ext.ID || len(album.Tracks) != 1 || album.Tracks[0].ProviderID != ext.ID {
|
|
||||||
t.Fatalf("album = %#v", album)
|
|
||||||
}
|
|
||||||
|
|
||||||
playlist, err := provider.GetPlaylist("playlist-1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetPlaylist: %v", err)
|
|
||||||
}
|
|
||||||
if playlist.Name != "Playlist playlist-1" || playlist.ProviderID != ext.ID {
|
|
||||||
t.Fatalf("playlist = %#v", playlist)
|
|
||||||
}
|
|
||||||
|
|
||||||
artist, err := provider.GetArtist("artist-1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetArtist: %v", err)
|
|
||||||
}
|
|
||||||
if artist.ProviderID != ext.ID || len(artist.Releases) != 1 || artist.Releases[0].ProviderID != ext.ID {
|
|
||||||
t.Fatalf("artist = %#v", artist)
|
|
||||||
}
|
|
||||||
|
|
||||||
enriched, err := provider.EnrichTrack(&ExtTrackMetadata{ID: "track-1", Name: "Old", ProviderID: ext.ID})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("EnrichTrack: %v", err)
|
|
||||||
}
|
|
||||||
if enriched.Name != "Enriched" || enriched.ProviderID != ext.ID {
|
|
||||||
t.Fatalf("enriched = %#v", enriched)
|
|
||||||
}
|
|
||||||
|
|
||||||
availability, err := provider.CheckAvailability("ISRC", "Song", "Artist", "spotify:1", "dz", "tidal", "qobuz")
|
|
||||||
if err != nil {
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Download: %v", err)
|
|
||||||
}
|
|
||||||
if !download.Success || download.Decryption == nil || download.DecryptionKey != "001122" || len(progress) != 1 || progress[0] != 100 {
|
|
||||||
t.Fatalf("download = %#v progress=%v", download, progress)
|
|
||||||
}
|
|
||||||
|
|
||||||
lyrics, err := provider.FetchLyrics("Song", "Artist", "Album", 180)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetLyrics: %v", err)
|
|
||||||
}
|
|
||||||
if lyrics.Provider != ext.ID || len(lyrics.Lines) != 1 || lyrics.Lines[0].Words != "Hello" {
|
|
||||||
t.Fatalf("lyrics = %#v", lyrics)
|
|
||||||
}
|
|
||||||
|
|
||||||
urlResult, err := provider.HandleURL("https://example.test/track/1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("HandleURL: %v", err)
|
|
||||||
}
|
|
||||||
if urlResult.Track == nil || urlResult.Track.Name == "" || len(urlResult.Tracks) != 1 || urlResult.Album == nil || urlResult.Artist == nil {
|
|
||||||
t.Fatalf("url result = %#v", urlResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
if !post.Success || post.BitDepth != 24 || post.SampleRate != 96000 {
|
|
||||||
t.Fatalf("post = %#v", post)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtensionProviderAndManagerSelectionHelpers(t *testing.T) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
if !extensionReplacesBuiltInProvider(&loadedExtension{Manifest: manifest}, "deezer") || extensionReplacesBuiltInProvider(nil, "deezer") {
|
|
||||||
t.Fatal("extension replacement mismatch")
|
|
||||||
}
|
|
||||||
if trimKnownProviderPrefix("Deezer:101", "deezer") != "101" || trimKnownProviderPrefix("101", "deezer") != "101" {
|
|
||||||
t.Fatal("trimKnownProviderPrefix mismatch")
|
|
||||||
}
|
|
||||||
if metadataTrackDedupKey(ExtTrackMetadata{ISRC: "usrc"}) != "isrc:USRC" ||
|
|
||||||
metadataTrackDedupKey(ExtTrackMetadata{SpotifyID: "sp"}) != "spotify:sp" ||
|
|
||||||
metadataTrackDedupKey(ExtTrackMetadata{ProviderID: "p", ID: "1"}) != "p:1" {
|
|
||||||
t.Fatal("metadata dedup key mismatch")
|
|
||||||
}
|
|
||||||
|
|
||||||
manager := &extensionManager{extensions: map[string]*loadedExtension{}}
|
|
||||||
downloadExt := newTestLoadedExtension(t, ExtensionTypeDownloadProvider, ExtensionTypeMetadataProvider)
|
|
||||||
manager.extensions[downloadExt.ID] = downloadExt
|
|
||||||
if providers := manager.GetDownloadProviders(); len(providers) != 1 {
|
|
||||||
t.Fatalf("download providers = %#v", providers)
|
|
||||||
}
|
|
||||||
SetProviderPriority([]string{"deezer", "coverage-ext", "coverage-ext", " "})
|
|
||||||
if priority := GetProviderPriority(); len(priority) != 1 || priority[0] != "coverage-ext" {
|
|
||||||
t.Fatalf("provider priority = %#v", priority)
|
|
||||||
}
|
|
||||||
SetExtensionFallbackProviderIDs([]string{"a", "a", " ", "b"})
|
|
||||||
if ids := GetExtensionFallbackProviderIDs(); len(ids) != 2 || !isExtensionFallbackAllowed("a") || isExtensionFallbackAllowed("z") {
|
|
||||||
t.Fatalf("fallback ids = %#v", ids)
|
|
||||||
}
|
|
||||||
SetExtensionFallbackProviderIDs(nil)
|
|
||||||
if !isExtensionFallbackAllowed("z") {
|
|
||||||
t.Fatal("nil fallback list should allow all")
|
|
||||||
}
|
|
||||||
SetMetadataProviderPriority([]string{"spotify", "deezer", "coverage-ext", "coverage-ext"})
|
|
||||||
if priority := GetMetadataProviderPriority(); len(priority) != 1 || priority[0] != "coverage-ext" {
|
|
||||||
t.Fatalf("metadata priority = %#v", priority)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +1,14 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import "testing"
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
||||||
)
|
|
||||||
|
|
||||||
func TestSetMetadataProviderPriorityStripsRetiredBuiltIns(t *testing.T) {
|
|
||||||
original := GetMetadataProviderPriority()
|
original := GetMetadataProviderPriority()
|
||||||
defer SetMetadataProviderPriority(original)
|
defer SetMetadataProviderPriority(original)
|
||||||
|
|
||||||
SetMetadataProviderPriority([]string{"qobuz"})
|
SetMetadataProviderPriority([]string{"tidal"})
|
||||||
got := GetMetadataProviderPriority()
|
got := GetMetadataProviderPriority()
|
||||||
if len(got) != 0 {
|
want := []string{"tidal", "deezer", "qobuz"}
|
||||||
t.Fatalf("expected retired built-in qobuz to be stripped, got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetExtensionFallbackProviderIDsDedupesExtensions(t *testing.T) {
|
|
||||||
original := GetExtensionFallbackProviderIDs()
|
|
||||||
defer SetExtensionFallbackProviderIDs(original)
|
|
||||||
|
|
||||||
SetExtensionFallbackProviderIDs([]string{"ext-a", "ext-a", " ext-b "})
|
|
||||||
|
|
||||||
got := GetExtensionFallbackProviderIDs()
|
|
||||||
want := []string{"ext-a", "ext-b"}
|
|
||||||
if len(got) != len(want) {
|
|
||||||
t.Fatalf("unexpected fallback provider length: got %v want %v", got, want)
|
|
||||||
}
|
|
||||||
for i := range want {
|
|
||||||
if got[i] != want[i] {
|
|
||||||
t.Fatalf("unexpected fallback provider at %d: got %v want %v", i, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsExtensionFallbackAllowedDefaultsToAllExtensions(t *testing.T) {
|
|
||||||
original := GetExtensionFallbackProviderIDs()
|
|
||||||
defer SetExtensionFallbackProviderIDs(original)
|
|
||||||
|
|
||||||
SetExtensionFallbackProviderIDs(nil)
|
|
||||||
|
|
||||||
if !isExtensionFallbackAllowed("custom-ext") {
|
|
||||||
t.Fatal("expected custom extension to be allowed when no fallback allowlist is configured")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsExtensionFallbackAllowedRespectsAllowlist(t *testing.T) {
|
|
||||||
original := GetExtensionFallbackProviderIDs()
|
|
||||||
defer SetExtensionFallbackProviderIDs(original)
|
|
||||||
|
|
||||||
SetExtensionFallbackProviderIDs([]string{"allowed-ext"})
|
|
||||||
|
|
||||||
if !isExtensionFallbackAllowed("allowed-ext") {
|
|
||||||
t.Fatal("expected explicitly allowed extension to be permitted")
|
|
||||||
}
|
|
||||||
if isExtensionFallbackAllowed("blocked-ext") {
|
|
||||||
t.Fatal("expected extension outside allowlist to be blocked")
|
|
||||||
}
|
|
||||||
if isExtensionFallbackAllowed("deezer") {
|
|
||||||
t.Fatal("expected retired Deezer downloader to respect extension fallback allowlist")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
|
|
||||||
original := GetProviderPriority()
|
|
||||||
defer SetProviderPriority(original)
|
|
||||||
|
|
||||||
SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"})
|
|
||||||
|
|
||||||
got := GetProviderPriority()
|
|
||||||
want := []string{"custom-ext"}
|
|
||||||
if len(got) != len(want) {
|
if len(got) != len(want) {
|
||||||
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||||
}
|
}
|
||||||
@@ -93,696 +19,50 @@ func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetProviderPriorityKeepsExtensionNamedLikeRetiredDownloader(t *testing.T) {
|
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
||||||
original := GetProviderPriority()
|
|
||||||
defer SetProviderPriority(original)
|
|
||||||
|
|
||||||
manager := getExtensionManager()
|
|
||||||
ext := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
|
||||||
ext.ID = "deezer"
|
|
||||||
ext.Manifest.Name = "deezer"
|
|
||||||
|
|
||||||
manager.mu.Lock()
|
|
||||||
previous, hadPrevious := manager.extensions[ext.ID]
|
|
||||||
manager.extensions[ext.ID] = ext
|
|
||||||
manager.mu.Unlock()
|
|
||||||
defer func() {
|
|
||||||
manager.mu.Lock()
|
|
||||||
if hadPrevious {
|
|
||||||
manager.extensions[ext.ID] = previous
|
|
||||||
} else {
|
|
||||||
delete(manager.extensions, ext.ID)
|
|
||||||
}
|
|
||||||
manager.mu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
SetProviderPriority([]string{"deezer", "custom-ext"})
|
|
||||||
|
|
||||||
got := GetProviderPriority()
|
|
||||||
want := []string{"deezer", "custom-ext"}
|
|
||||||
if len(got) != len(want) {
|
|
||||||
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
|
||||||
}
|
|
||||||
for i := range want {
|
|
||||||
if got[i] != want[i] {
|
|
||||||
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPrioritizeFallbackProvidersByHealthPrefersOnlineAndSkipsOffline(t *testing.T) {
|
|
||||||
manager := getExtensionManager()
|
|
||||||
amazon := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
|
||||||
amazon.ID = "amazon"
|
|
||||||
amazon.Manifest.Name = "amazon"
|
|
||||||
amazon.Manifest.ServiceHealth = []ExtensionHealthCheck{{
|
|
||||||
ID: "main",
|
|
||||||
URL: "://bad",
|
|
||||||
Required: true,
|
|
||||||
}}
|
|
||||||
|
|
||||||
plain := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
|
||||||
plain.ID = "plain"
|
|
||||||
plain.Manifest.Name = "plain"
|
|
||||||
|
|
||||||
deezer := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
|
||||||
deezer.ID = "deezer"
|
|
||||||
deezer.Manifest.Name = "deezer"
|
|
||||||
deezer.Manifest.ServiceHealth = []ExtensionHealthCheck{{
|
|
||||||
ID: "main",
|
|
||||||
URL: "https://example.test/health",
|
|
||||||
}}
|
|
||||||
|
|
||||||
manager.mu.Lock()
|
|
||||||
previousAmazon, hadAmazon := manager.extensions[amazon.ID]
|
|
||||||
previousPlain, hadPlain := manager.extensions[plain.ID]
|
|
||||||
previousDeezer, hadDeezer := manager.extensions[deezer.ID]
|
|
||||||
manager.extensions[amazon.ID] = amazon
|
|
||||||
manager.extensions[plain.ID] = plain
|
|
||||||
manager.extensions[deezer.ID] = deezer
|
|
||||||
manager.mu.Unlock()
|
|
||||||
defer func() {
|
|
||||||
manager.mu.Lock()
|
|
||||||
if hadAmazon {
|
|
||||||
manager.extensions[amazon.ID] = previousAmazon
|
|
||||||
} else {
|
|
||||||
delete(manager.extensions, amazon.ID)
|
|
||||||
}
|
|
||||||
if hadPlain {
|
|
||||||
manager.extensions[plain.ID] = previousPlain
|
|
||||||
} else {
|
|
||||||
delete(manager.extensions, plain.ID)
|
|
||||||
}
|
|
||||||
if hadDeezer {
|
|
||||||
manager.extensions[deezer.ID] = previousDeezer
|
|
||||||
} else {
|
|
||||||
delete(manager.extensions, deezer.ID)
|
|
||||||
}
|
|
||||||
manager.mu.Unlock()
|
|
||||||
|
|
||||||
extensionHealthCacheMu.Lock()
|
|
||||||
delete(extensionHealthCache, deezer.ID)
|
|
||||||
extensionHealthCacheMu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
extensionHealthCacheMu.Lock()
|
|
||||||
extensionHealthCache[deezer.ID] = cachedExtensionHealthResult{
|
|
||||||
result: ExtensionHealthResult{
|
|
||||||
ExtensionID: deezer.ID,
|
|
||||||
Status: "online",
|
|
||||||
CheckedAt: time.Now().UTC().Format(time.RFC3339),
|
|
||||||
},
|
|
||||||
expiresAt: time.Now().Add(time.Minute),
|
|
||||||
}
|
|
||||||
extensionHealthCacheMu.Unlock()
|
|
||||||
|
|
||||||
got := prioritizeFallbackProvidersByHealth(
|
|
||||||
[]string{"amazon", "plain", "deezer"},
|
|
||||||
manager,
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
want := []string{"deezer", "plain"}
|
|
||||||
if len(got) != len(want) {
|
|
||||||
t.Fatalf("unexpected provider order length: got %v want %v", got, want)
|
|
||||||
}
|
|
||||||
for i := range want {
|
|
||||||
if got[i] != want[i] {
|
|
||||||
t.Fatalf("unexpected provider order at %d: got %v want %v", i, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
|
|
||||||
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
|
|
||||||
if normalized == nil {
|
|
||||||
t.Fatal("expected legacy decryption key to produce normalized descriptor")
|
|
||||||
}
|
|
||||||
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
|
|
||||||
t.Fatalf("strategy = %q", normalized.Strategy)
|
|
||||||
}
|
|
||||||
if normalized.Key != "001122" {
|
|
||||||
t.Fatalf("key = %q", normalized.Key)
|
|
||||||
}
|
|
||||||
if normalized.InputFormat != "mov" {
|
|
||||||
t.Fatalf("input format = %q", normalized.InputFormat)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeDownloadDecryptionInfoCanonicalizesMovAliases(t *testing.T) {
|
|
||||||
normalized := normalizeDownloadDecryptionInfo(&DownloadDecryptionInfo{
|
|
||||||
Strategy: "mp4_decryption_key",
|
|
||||||
Key: "abcd",
|
|
||||||
InputFormat: "",
|
|
||||||
}, "")
|
|
||||||
if normalized == nil {
|
|
||||||
t.Fatal("expected descriptor to remain available")
|
|
||||||
}
|
|
||||||
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
|
|
||||||
t.Fatalf("strategy = %q", normalized.Strategy)
|
|
||||||
}
|
|
||||||
if normalized.InputFormat != "mov" {
|
|
||||||
t.Fatalf("input format = %q", normalized.InputFormat)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtensionDownloadUsesIsolatedRuntimeForConcurrentCalls(t *testing.T) {
|
|
||||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
_, _ = w.Write([]byte("ok"))
|
|
||||||
}))
|
|
||||||
defer server.Close()
|
|
||||||
setPrivateIPCache("download.test", false, time.Minute)
|
|
||||||
|
|
||||||
originalTransport := sharedTransport
|
|
||||||
testTransport := &http.Transport{
|
|
||||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
||||||
return (&net.Dialer{}).DialContext(ctx, network, server.Listener.Addr().String())
|
|
||||||
},
|
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
||||||
}
|
|
||||||
sharedTransport = testTransport
|
|
||||||
defer func() {
|
|
||||||
testTransport.CloseIdleConnections()
|
|
||||||
sharedTransport = originalTransport
|
|
||||||
}()
|
|
||||||
|
|
||||||
extDir := t.TempDir()
|
|
||||||
if err := os.WriteFile(filepath.Join(extDir, "index.js"), []byte(`
|
|
||||||
registerExtension({
|
|
||||||
download: function(trackID, quality, outputPath, onProgress) {
|
|
||||||
var result = file.download('https://download.test/' + trackID, outputPath, {
|
|
||||||
onProgress: function(written, total) {
|
|
||||||
if (onProgress) onProgress(50);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!result || !result.success) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error_message: result && result.error ? result.error : 'download failed',
|
|
||||||
error_type: 'download_error'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (onProgress) onProgress(100);
|
|
||||||
return { success: true, file_path: result.path };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
`), 0600); err != nil {
|
|
||||||
t.Fatalf("write extension index: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
outputDir := t.TempDir()
|
|
||||||
SetAllowedDownloadDirs([]string{outputDir})
|
|
||||||
defer SetAllowedDownloadDirs(nil)
|
|
||||||
|
|
||||||
ext := &loadedExtension{
|
|
||||||
ID: "concurrent-download",
|
|
||||||
Manifest: &ExtensionManifest{
|
|
||||||
Name: "concurrent-download",
|
|
||||||
Description: "Concurrent download test",
|
|
||||||
Version: "1.0.0",
|
|
||||||
Types: []ExtensionType{ExtensionTypeDownloadProvider},
|
|
||||||
Permissions: ExtensionPermissions{
|
|
||||||
Network: []string{"download.test"},
|
|
||||||
File: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Enabled: true,
|
|
||||||
SourceDir: extDir,
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
}
|
|
||||||
provider := newExtensionProviderWrapper(ext)
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
errs := make(chan error, 2)
|
|
||||||
for i := 0; i < 2; i++ {
|
|
||||||
i := i
|
|
||||||
wg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
result, err := provider.Download(
|
|
||||||
fmt.Sprintf("track-%d", i),
|
|
||||||
"LOSSLESS",
|
|
||||||
filepath.Join(outputDir, fmt.Sprintf("track-%d.flac", i)),
|
|
||||||
"",
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
errs <- err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if result == nil || !result.Success {
|
|
||||||
errs <- fmt.Errorf("download failed: %#v", result)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
close(errs)
|
|
||||||
for err := range errs {
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if elapsed := time.Since(start); elapsed >= 850*time.Millisecond {
|
|
||||||
t.Fatalf("expected same-extension downloads to overlap, elapsed %s", elapsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildOutputPathAddsExplicitOutputDirToAllowedDirs(t *testing.T) {
|
|
||||||
SetAllowedDownloadDirs(nil)
|
|
||||||
|
|
||||||
outputDir := t.TempDir()
|
|
||||||
outputPath := buildOutputPath(DownloadRequest{
|
|
||||||
TrackName: "Song",
|
|
||||||
ArtistName: "Artist",
|
|
||||||
OutputDir: outputDir,
|
|
||||||
OutputExt: ".flac",
|
|
||||||
FilenameFormat: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
if !isPathInAllowedDirs(outputPath) {
|
|
||||||
t.Fatalf("expected output path %q to be allowed", outputPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildOutputPathForExtensionAddsExplicitOutputPathDirToAllowedDirs(t *testing.T) {
|
|
||||||
SetAllowedDownloadDirs(nil)
|
|
||||||
|
|
||||||
outputDir := t.TempDir()
|
|
||||||
outputPath := filepath.Join(outputDir, "custom.flac")
|
|
||||||
ext := &loadedExtension{DataDir: t.TempDir()}
|
|
||||||
|
|
||||||
resolved := buildOutputPathForExtension(DownloadRequest{
|
|
||||||
OutputPath: outputPath,
|
|
||||||
}, ext)
|
|
||||||
|
|
||||||
if resolved != outputPath {
|
|
||||||
t.Fatalf("resolved output path = %q", resolved)
|
|
||||||
}
|
|
||||||
if !isPathInAllowedDirs(outputPath) {
|
|
||||||
t.Fatalf("expected output path %q to be allowed", outputPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
|
|
||||||
SetAllowedDownloadDirs(nil)
|
|
||||||
|
|
||||||
ext := &loadedExtension{DataDir: t.TempDir()}
|
|
||||||
resolved := buildOutputPathForExtension(DownloadRequest{
|
|
||||||
TrackName: "Song",
|
|
||||||
ArtistName: "Artist",
|
|
||||||
OutputDir: filepath.Join("Artist", "Album"),
|
|
||||||
OutputFD: 123,
|
|
||||||
OutputExt: ".flac",
|
|
||||||
}, ext)
|
|
||||||
|
|
||||||
expectedBase := filepath.Join(ext.DataDir, "downloads")
|
|
||||||
if !isPathWithinBase(expectedBase, resolved) {
|
|
||||||
t.Fatalf("expected SAF extension output under %q, got %q", expectedBase, resolved)
|
|
||||||
}
|
|
||||||
if !isPathInAllowedDirs(resolved) {
|
|
||||||
t.Fatalf("expected resolved output path %q to be allowed", resolved)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildOutputPathSanitizesTemplateFilename(t *testing.T) {
|
|
||||||
SetAllowedDownloadDirs(nil)
|
|
||||||
|
|
||||||
outputDir := t.TempDir()
|
|
||||||
outputPath := buildOutputPath(DownloadRequest{
|
|
||||||
TrackName: `Gehra Hua (From "Dhurandhar")`,
|
|
||||||
ArtistName: "Artist",
|
|
||||||
OutputDir: outputDir,
|
|
||||||
OutputExt: ".flac",
|
|
||||||
FilenameFormat: "{artist} - {title}",
|
|
||||||
})
|
|
||||||
|
|
||||||
base := filepath.Base(outputPath)
|
|
||||||
if strings.ContainsAny(base, `<>:"/\|?*`) {
|
|
||||||
t.Fatalf("output filename still contains illegal characters: %q", base)
|
|
||||||
}
|
|
||||||
if strings.Contains(base, `"`) {
|
|
||||||
t.Fatalf("output filename still contains straight double quote: %q", base)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildOutputPathForExtensionSanitizesTemplateFilename(t *testing.T) {
|
|
||||||
SetAllowedDownloadDirs(nil)
|
|
||||||
|
|
||||||
ext := &loadedExtension{DataDir: t.TempDir()}
|
|
||||||
resolved := buildOutputPathForExtension(DownloadRequest{
|
|
||||||
TrackName: `Gehra Hua (From "Dhurandhar")`,
|
|
||||||
ArtistName: "Artist",
|
|
||||||
OutputFD: 123,
|
|
||||||
OutputExt: ".flac",
|
|
||||||
FilenameFormat: "{artist} - {title}",
|
|
||||||
}, ext)
|
|
||||||
|
|
||||||
base := filepath.Base(resolved)
|
|
||||||
if strings.ContainsAny(base, `<>:"/\|?*`) {
|
|
||||||
t.Fatalf("extension output filename still contains illegal characters: %q", base)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShouldStopProviderFallback(t *testing.T) {
|
|
||||||
if shouldStopProviderFallback(nil) {
|
|
||||||
t.Fatal("nil availability should not stop fallback")
|
|
||||||
}
|
|
||||||
if shouldStopProviderFallback(&ExtAvailabilityResult{Available: false}) {
|
|
||||||
t.Fatal("availability without skip_fallback should not stop fallback")
|
|
||||||
}
|
|
||||||
if !shouldStopProviderFallback(&ExtAvailabilityResult{Available: false, SkipFallback: true}) {
|
|
||||||
t.Fatal("skip_fallback availability should stop fallback")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildExtensionFallbackStoppedResponsePrefersAvailabilityReason(t *testing.T) {
|
|
||||||
resp := buildExtensionFallbackStoppedResponse("soundcloud", &ExtAvailabilityResult{
|
|
||||||
Reason: "direct SoundCloud track ID",
|
|
||||||
SkipFallback: true,
|
|
||||||
}, errors.New("ignored"))
|
|
||||||
|
|
||||||
if resp.Service != "soundcloud" {
|
|
||||||
t.Fatalf("service = %q", resp.Service)
|
|
||||||
}
|
|
||||||
if resp.Error != "Fallback stopped by soundcloud: direct SoundCloud track ID" {
|
|
||||||
t.Fatalf("unexpected error message: %q", resp.Error)
|
|
||||||
}
|
|
||||||
if resp.ErrorType != "extension_error" {
|
|
||||||
t.Fatalf("error type = %q", resp.ErrorType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildExtensionFallbackStoppedResponseFallsBackToError(t *testing.T) {
|
|
||||||
resp := buildExtensionFallbackStoppedResponse("soundcloud", &ExtAvailabilityResult{
|
|
||||||
SkipFallback: true,
|
|
||||||
}, errors.New("lookup failed"))
|
|
||||||
|
|
||||||
if resp.Error != "Fallback stopped by soundcloud: lookup failed" {
|
|
||||||
t.Fatalf("unexpected error message: %q", resp.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShouldAbortCancelledFallbackWithCancelledError(t *testing.T) {
|
|
||||||
if !shouldAbortCancelledFallback("", ErrDownloadCancelled) {
|
|
||||||
t.Fatal("expected cancelled error to abort fallback")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShouldAbortCancelledFallbackWithCancelledItemState(t *testing.T) {
|
|
||||||
const itemID = "cancelled-item"
|
|
||||||
initDownloadCancel(itemID)
|
|
||||||
defer clearDownloadCancel(itemID)
|
|
||||||
|
|
||||||
cancelDownload(itemID)
|
|
||||||
|
|
||||||
if !shouldAbortCancelledFallback(itemID, errors.New("generic failure")) {
|
|
||||||
t.Fatal("expected cancelled item state to abort fallback even for generic errors")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
|
|
||||||
tempFile := filepath.Join(t.TempDir(), "track.flac")
|
|
||||||
if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil {
|
|
||||||
t.Fatalf("failed to create temp file: %v", err)
|
|
||||||
}
|
|
||||||
tempM4A := filepath.Join(t.TempDir(), "track.m4a")
|
|
||||||
if err := os.WriteFile(tempM4A, []byte("not-flac"), 0644); err != nil {
|
|
||||||
t.Fatalf("failed to create temp m4a file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if canEmbedGenreLabel("relative.flac") {
|
|
||||||
t.Fatal("expected relative path to be rejected")
|
|
||||||
}
|
|
||||||
if canEmbedGenreLabel("content://example") {
|
|
||||||
t.Fatal("expected content URI to be rejected")
|
|
||||||
}
|
|
||||||
if canEmbedGenreLabel(filepath.Join(t.TempDir(), "missing.flac")) {
|
|
||||||
t.Fatal("expected missing file to be rejected")
|
|
||||||
}
|
|
||||||
if canEmbedGenreLabel(tempM4A) {
|
|
||||||
t.Fatalf("expected non-FLAC file %q to be rejected", tempM4A)
|
|
||||||
}
|
|
||||||
if !canEmbedGenreLabel(tempFile) {
|
|
||||||
t.Fatalf("expected existing absolute file %q to be accepted", tempFile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSearchTracksWithMetadataProvidersIgnoresRetiredBuiltIns(t *testing.T) {
|
|
||||||
originalPriority := GetMetadataProviderPriority()
|
originalPriority := GetMetadataProviderPriority()
|
||||||
|
originalSearch := searchBuiltInMetadataTracksFunc
|
||||||
defer func() {
|
defer func() {
|
||||||
SetMetadataProviderPriority(originalPriority)
|
SetMetadataProviderPriority(originalPriority)
|
||||||
|
searchBuiltInMetadataTracksFunc = originalSearch
|
||||||
}()
|
}()
|
||||||
|
|
||||||
SetMetadataProviderPriority([]string{"qobuz"})
|
SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"})
|
||||||
|
|
||||||
manager := getExtensionManager()
|
var calls []string
|
||||||
|
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
||||||
|
calls = append(calls, providerID)
|
||||||
|
switch providerID {
|
||||||
|
case "qobuz":
|
||||||
|
return []ExtTrackMetadata{
|
||||||
|
{ProviderID: "qobuz", SpotifyID: "qobuz:1", ISRC: "AAA111", Name: "First"},
|
||||||
|
}, nil
|
||||||
|
case "tidal":
|
||||||
|
return []ExtTrackMetadata{
|
||||||
|
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
|
||||||
|
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
|
||||||
|
}, nil
|
||||||
|
case "deezer":
|
||||||
|
return []ExtTrackMetadata{
|
||||||
|
{ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"},
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := GetExtensionManager()
|
||||||
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
||||||
}
|
}
|
||||||
if len(tracks) != 0 {
|
if len(tracks) != 3 {
|
||||||
t.Fatalf("expected no tracks from retired built-in provider, got %+v", tracks)
|
t.Fatalf("unexpected track count: got %d want 3", len(tracks))
|
||||||
}
|
}
|
||||||
}
|
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" {
|
||||||
|
t.Fatalf("unexpected track provider order: %+v", tracks)
|
||||||
func TestParseExtensionSearchResultAcceptsObjectAndArrayShapes(t *testing.T) {
|
}
|
||||||
vm := goja.New()
|
if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" {
|
||||||
value, err := vm.RunString(`({
|
t.Fatalf("unexpected provider call order: %v", calls)
|
||||||
tracks: [{
|
|
||||||
id: "track-1",
|
|
||||||
name: "Song",
|
|
||||||
artists: "Artist",
|
|
||||||
album_name: "Album",
|
|
||||||
duration_ms: 123000,
|
|
||||||
cover_url: "https://img.test/cover.jpg",
|
|
||||||
external_links: { spotify: "spotify:track:1" },
|
|
||||||
audio_quality: "LOSSLESS"
|
|
||||||
}],
|
|
||||||
total: 9
|
|
||||||
})`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build object search result: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := parseExtensionSearchResult(vm, value)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse object search result: %v", err)
|
|
||||||
}
|
|
||||||
if result.Total != 9 || len(result.Tracks) != 1 {
|
|
||||||
t.Fatalf("unexpected object result: %+v", result)
|
|
||||||
}
|
|
||||||
track := result.Tracks[0]
|
|
||||||
if track.ID != "track-1" ||
|
|
||||||
track.AlbumName != "Album" ||
|
|
||||||
track.DurationMS != 123000 ||
|
|
||||||
track.CoverURL != "https://img.test/cover.jpg" ||
|
|
||||||
track.ExternalLinks["spotify"] != "spotify:track:1" ||
|
|
||||||
track.AudioQuality != "LOSSLESS" {
|
|
||||||
t.Fatalf("unexpected parsed track: %+v", track)
|
|
||||||
}
|
|
||||||
|
|
||||||
arrayValue, err := vm.RunString(`[
|
|
||||||
{id: "track-2", name: "Other Song", artists: "Other Artist", albumName: "Other Album", durationMs: 456000}
|
|
||||||
]`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build array search result: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
arrayResult, err := parseExtensionSearchResult(vm, arrayValue)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse array search result: %v", err)
|
|
||||||
}
|
|
||||||
if arrayResult.Total != 1 ||
|
|
||||||
len(arrayResult.Tracks) != 1 ||
|
|
||||||
arrayResult.Tracks[0].AlbumName != "Other Album" ||
|
|
||||||
arrayResult.Tracks[0].DurationMS != 456000 {
|
|
||||||
t.Fatalf("unexpected array result: %+v", arrayResult)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseExtensionMetadataAndDownloadResults(t *testing.T) {
|
|
||||||
vm := goja.New()
|
|
||||||
value, err := vm.RunString(`({
|
|
||||||
id: "album-1",
|
|
||||||
name: "Album",
|
|
||||||
artists: "Artist",
|
|
||||||
artistId: "artist-1",
|
|
||||||
coverUrl: "https://img.test/album.jpg",
|
|
||||||
releaseDate: "2024-02-03",
|
|
||||||
totalTracks: 2,
|
|
||||||
albumType: "album",
|
|
||||||
tracks: [
|
|
||||||
{id: "track-1", name: "Song 1", artists: "Artist", durationMs: 180000},
|
|
||||||
{id: "track-2", name: "Song 2", artists: "Artist", duration_ms: 181000}
|
|
||||||
]
|
|
||||||
})`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build album value: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
album, err := parseExtensionAlbumValue(vm, value)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse album: %v", err)
|
|
||||||
}
|
|
||||||
if album.ID != "album-1" ||
|
|
||||||
album.ArtistID != "artist-1" ||
|
|
||||||
album.CoverURL != "https://img.test/album.jpg" ||
|
|
||||||
album.TotalTracks != 2 ||
|
|
||||||
len(album.Tracks) != 2 ||
|
|
||||||
album.Tracks[0].DurationMS != 180000 ||
|
|
||||||
album.Tracks[1].DurationMS != 181000 {
|
|
||||||
t.Fatalf("unexpected album: %+v", album)
|
|
||||||
}
|
|
||||||
|
|
||||||
artistValue, err := vm.RunString(`({
|
|
||||||
id: "artist-1",
|
|
||||||
name: "Artist",
|
|
||||||
imageUrl: "https://img.test/artist.jpg",
|
|
||||||
headerImage: "https://img.test/header.jpg",
|
|
||||||
listeners: 1234,
|
|
||||||
albums: [{id: "album-1", name: "Album", tracks: [{id: "track-1", name: "Song"}]}],
|
|
||||||
releases: [{id: "single-1", name: "Single"}],
|
|
||||||
topTracks: [{id: "top-1", name: "Top Song"}]
|
|
||||||
})`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build artist value: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
artist, err := parseExtensionArtistValue(vm, artistValue)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse artist: %v", err)
|
|
||||||
}
|
|
||||||
if artist.ID != "artist-1" ||
|
|
||||||
artist.ImageURL != "https://img.test/artist.jpg" ||
|
|
||||||
artist.HeaderImage != "https://img.test/header.jpg" ||
|
|
||||||
artist.Listeners != 1234 ||
|
|
||||||
len(artist.Albums) != 1 ||
|
|
||||||
len(artist.Albums[0].Tracks) != 1 ||
|
|
||||||
len(artist.Releases) != 1 ||
|
|
||||||
len(artist.TopTracks) != 1 {
|
|
||||||
t.Fatalf("unexpected artist: %+v", artist)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadValue, err := vm.RunString(`({
|
|
||||||
success: true,
|
|
||||||
filePath: "/tmp/song.flac",
|
|
||||||
alreadyExists: true,
|
|
||||||
bitDepth: 24,
|
|
||||||
sampleRate: 96000,
|
|
||||||
title: "Song",
|
|
||||||
albumArtist: "Album Artist",
|
|
||||||
lyricsLrc: "[00:00.00]Line",
|
|
||||||
decryptionKey: "001122",
|
|
||||||
decryption: {
|
|
||||||
strategy: "mp4_decryption_key",
|
|
||||||
key: "001122",
|
|
||||||
inputFormat: "m4a",
|
|
||||||
options: { map: "0:a" }
|
|
||||||
}
|
|
||||||
})`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build download value: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
download := parseExtensionDownloadResultValue(vm, downloadValue)
|
|
||||||
if !download.Success ||
|
|
||||||
download.FilePath != "/tmp/song.flac" ||
|
|
||||||
!download.AlreadyExists ||
|
|
||||||
download.BitDepth != 24 ||
|
|
||||||
download.SampleRate != 96000 ||
|
|
||||||
download.AlbumArtist != "Album Artist" ||
|
|
||||||
download.LyricsLRC != "[00:00.00]Line" ||
|
|
||||||
download.Decryption == nil ||
|
|
||||||
download.Decryption.InputFormat != "m4a" ||
|
|
||||||
download.Decryption.Options["map"] != "0:a" {
|
|
||||||
t.Fatalf("unexpected download result: %+v", download)
|
|
||||||
}
|
|
||||||
|
|
||||||
availabilityValue, err := vm.RunString(`({ available: true, trackId: "track-1", skipFallback: true, reason: "direct" })`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build availability value: %v", err)
|
|
||||||
}
|
|
||||||
availability := parseExtensionAvailabilityValue(vm, availabilityValue)
|
|
||||||
if !availability.Available || availability.TrackID != "track-1" || !availability.SkipFallback || availability.Reason != "direct" {
|
|
||||||
t.Fatalf("unexpected availability: %+v", availability)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseExtensionURLHandleResult(t *testing.T) {
|
|
||||||
vm := goja.New()
|
|
||||||
value, err := vm.RunString(`({
|
|
||||||
type: "album",
|
|
||||||
name: "Shared Album",
|
|
||||||
coverUrl: "https://img.test/shared.jpg",
|
|
||||||
track: { id: "track-1", name: "Song" },
|
|
||||||
tracks: [{ id: "track-2", name: "Song 2" }],
|
|
||||||
album: { id: "album-1", name: "Album", tracks: [{ id: "track-3", name: "Song 3" }] },
|
|
||||||
artist: { id: "artist-1", name: "Artist", topTracks: [{ id: "track-4", name: "Song 4" }] }
|
|
||||||
})`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build URL handle value: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := parseExtensionURLHandleValue(vm, value)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse URL handle: %v", err)
|
|
||||||
}
|
|
||||||
if result.Type != "album" ||
|
|
||||||
result.CoverURL != "https://img.test/shared.jpg" ||
|
|
||||||
result.Track == nil ||
|
|
||||||
result.Track.ID != "track-1" ||
|
|
||||||
len(result.Tracks) != 1 ||
|
|
||||||
result.Album == nil ||
|
|
||||||
len(result.Album.Tracks) != 1 ||
|
|
||||||
result.Artist == nil ||
|
|
||||||
len(result.Artist.TopTracks) != 1 {
|
|
||||||
t.Fatalf("unexpected URL handle result: %+v", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
post := parseExtensionPostProcessValue(vm, postValue)
|
|
||||||
if !post.Success || post.NewFilePath != "/tmp/new.flac" || post.NewFileURI != "content://new" || post.BitDepth != 24 || post.SampleRate != 96000 {
|
|
||||||
t.Fatalf("unexpected post-process result: %+v", post)
|
|
||||||
}
|
|
||||||
|
|
||||||
lyricsValue, err := vm.RunString(`({
|
|
||||||
syncType: "LINE_SYNCED",
|
|
||||||
instrumental: false,
|
|
||||||
plainLyrics: "Line",
|
|
||||||
provider: "Lyrics Provider",
|
|
||||||
lines: [{ startTimeMs: 1000, words: "Line", endTimeMs: 2000 }]
|
|
||||||
})`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("build lyrics value: %v", err)
|
|
||||||
}
|
|
||||||
lyrics, err := parseExtensionLyricsValue(vm, lyricsValue)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse lyrics: %v", err)
|
|
||||||
}
|
|
||||||
if lyrics.SyncType != "LINE_SYNCED" ||
|
|
||||||
lyrics.PlainLyrics != "Line" ||
|
|
||||||
lyrics.Provider != "Lyrics Provider" ||
|
|
||||||
len(lyrics.Lines) != 1 ||
|
|
||||||
lyrics.Lines[0].StartTimeMs != 1000 ||
|
|
||||||
lyrics.Lines[0].EndTimeMs != 2000 {
|
|
||||||
t.Fatalf("unexpected lyrics result: %+v", lyrics)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,38 +5,13 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"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
|
const DefaultJSTimeout = 30 * time.Second
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -105,7 +80,7 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
|||||||
state.IsAuthenticated = accessToken != ""
|
state.IsAuthenticated = accessToken != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
type extensionRuntime struct {
|
type ExtensionRuntime struct {
|
||||||
extensionID string
|
extensionID string
|
||||||
manifest *ExtensionManifest
|
manifest *ExtensionManifest
|
||||||
settings map[string]interface{}
|
settings map[string]interface{}
|
||||||
@@ -118,9 +93,6 @@ type extensionRuntime struct {
|
|||||||
activeDownloadMu sync.RWMutex
|
activeDownloadMu sync.RWMutex
|
||||||
activeDownloadItemID string
|
activeDownloadItemID string
|
||||||
|
|
||||||
activeRequestMu sync.RWMutex
|
|
||||||
activeRequestID string
|
|
||||||
|
|
||||||
storageMu sync.RWMutex
|
storageMu sync.RWMutex
|
||||||
storageCache map[string]interface{}
|
storageCache map[string]interface{}
|
||||||
storageLoaded bool
|
storageLoaded bool
|
||||||
@@ -151,10 +123,10 @@ var (
|
|||||||
privateIPCacheMu sync.RWMutex
|
privateIPCacheMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
|
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||||
jar, _ := newSimpleCookieJar()
|
jar, _ := newSimpleCookieJar()
|
||||||
|
|
||||||
runtime := &extensionRuntime{
|
runtime := &ExtensionRuntime{
|
||||||
extensionID: ext.ID,
|
extensionID: ext.ID,
|
||||||
manifest: ext.Manifest,
|
manifest: ext.Manifest,
|
||||||
settings: make(map[string]interface{}),
|
settings: make(map[string]interface{}),
|
||||||
@@ -164,131 +136,42 @@ func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
|
|||||||
storageFlushDelay: defaultStorageFlushDelay,
|
storageFlushDelay: defaultStorageFlushDelay,
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second), true)
|
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
|
||||||
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout, false)
|
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
|
||||||
|
|
||||||
return runtime
|
return runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
func extensionHTTPTimeout(ext *loadedExtension, fallback time.Duration) time.Duration {
|
func (r *ExtensionRuntime) setActiveDownloadItemID(itemID string) {
|
||||||
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, ok := ext.Manifest.Capabilities["networkTimeoutSeconds"]
|
|
||||||
if !ok {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
seconds := parseExtensionTimeoutSeconds(raw)
|
|
||||||
if seconds <= 0 {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
if seconds < 5 {
|
|
||||||
seconds = 5
|
|
||||||
}
|
|
||||||
if seconds > 300 {
|
|
||||||
seconds = 300
|
|
||||||
}
|
|
||||||
|
|
||||||
return time.Duration(seconds) * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseExtensionTimeoutSeconds(raw interface{}) int {
|
|
||||||
switch v := raw.(type) {
|
|
||||||
case int:
|
|
||||||
return v
|
|
||||||
case int32:
|
|
||||||
return int(v)
|
|
||||||
case int64:
|
|
||||||
return int(v)
|
|
||||||
case float32:
|
|
||||||
return int(v)
|
|
||||||
case float64:
|
|
||||||
return int(v)
|
|
||||||
case string:
|
|
||||||
parsed, err := strconv.Atoi(strings.TrimSpace(v))
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *extensionRuntime) setActiveDownloadItemID(itemID string) {
|
|
||||||
r.activeDownloadMu.Lock()
|
r.activeDownloadMu.Lock()
|
||||||
defer r.activeDownloadMu.Unlock()
|
defer r.activeDownloadMu.Unlock()
|
||||||
r.activeDownloadItemID = strings.TrimSpace(itemID)
|
r.activeDownloadItemID = strings.TrimSpace(itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) clearActiveDownloadItemID() {
|
func (r *ExtensionRuntime) clearActiveDownloadItemID() {
|
||||||
r.activeDownloadMu.Lock()
|
r.activeDownloadMu.Lock()
|
||||||
defer r.activeDownloadMu.Unlock()
|
defer r.activeDownloadMu.Unlock()
|
||||||
r.activeDownloadItemID = ""
|
r.activeDownloadItemID = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) getActiveDownloadItemID() string {
|
func (r *ExtensionRuntime) getActiveDownloadItemID() string {
|
||||||
r.activeDownloadMu.RLock()
|
r.activeDownloadMu.RLock()
|
||||||
defer r.activeDownloadMu.RUnlock()
|
defer r.activeDownloadMu.RUnlock()
|
||||||
return r.activeDownloadItemID
|
return r.activeDownloadItemID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) setActiveRequestID(requestID string) {
|
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
||||||
r.activeRequestMu.Lock()
|
|
||||||
defer r.activeRequestMu.Unlock()
|
|
||||||
r.activeRequestID = strings.TrimSpace(requestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *extensionRuntime) clearActiveRequestID() {
|
|
||||||
r.activeRequestMu.Lock()
|
|
||||||
defer r.activeRequestMu.Unlock()
|
|
||||||
r.activeRequestID = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *extensionRuntime) getActiveRequestID() string {
|
|
||||||
r.activeRequestMu.RLock()
|
|
||||||
defer r.activeRequestMu.RUnlock()
|
|
||||||
return r.activeRequestID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Request {
|
|
||||||
if req == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
itemID := r.getActiveDownloadItemID()
|
|
||||||
if itemID == "" {
|
|
||||||
requestID := r.getActiveRequestID()
|
|
||||||
if requestID == "" {
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
return req.WithContext(initExtensionRequestCancel(requestID))
|
|
||||||
}
|
|
||||||
|
|
||||||
return req.WithContext(initDownloadCancel(itemID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration, compressResponses bool) *http.Client {
|
|
||||||
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||||
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
||||||
// API calls can use response compression for faster metadata/search loads,
|
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
||||||
// while media downloads keep identity transfer semantics for progress/streaming.
|
|
||||||
transport := sharedTransport
|
|
||||||
if compressResponses {
|
|
||||||
transport = extensionAPITransport
|
|
||||||
}
|
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Transport: transport,
|
Transport: sharedTransport,
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Jar: jar,
|
Jar: jar,
|
||||||
}
|
}
|
||||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||||
if req.URL.Scheme != "https" &&
|
if req.URL.Scheme != "https" {
|
||||||
!(req.URL.Scheme == "http" && ext.Manifest.Permissions.AllowHTTP) {
|
|
||||||
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
|
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
|
||||||
return fmt.Errorf("redirect blocked: only https is allowed")
|
return fmt.Errorf("redirect blocked: only https is allowed")
|
||||||
}
|
}
|
||||||
@@ -327,12 +210,6 @@ func (e *RedirectBlockedError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isPrivateIP(host string) bool {
|
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))
|
hostLower := strings.ToLower(strings.TrimSpace(host))
|
||||||
if hostLower == "" {
|
if hostLower == "" {
|
||||||
return false
|
return false
|
||||||
@@ -452,11 +329,11 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
|||||||
return j.cookies[u.Host]
|
return j.cookies[u.Host]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) SetSettings(settings map[string]interface{}) {
|
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||||
r.settings = settings
|
r.settings = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||||
r.vm = vm
|
r.vm = vm
|
||||||
|
|
||||||
httpObj := vm.NewObject()
|
httpObj := vm.NewObject()
|
||||||
@@ -495,23 +372,12 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||||
vm.Set("auth", authObj)
|
vm.Set("auth", authObj)
|
||||||
|
|
||||||
if r.manifest != nil && 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 := vm.NewObject()
|
||||||
fileObj.Set("download", r.fileDownload)
|
fileObj.Set("download", r.fileDownload)
|
||||||
fileObj.Set("exists", r.fileExists)
|
fileObj.Set("exists", r.fileExists)
|
||||||
fileObj.Set("delete", r.fileDelete)
|
fileObj.Set("delete", r.fileDelete)
|
||||||
fileObj.Set("read", r.fileRead)
|
fileObj.Set("read", r.fileRead)
|
||||||
fileObj.Set("readBytes", r.fileReadBytes)
|
|
||||||
fileObj.Set("write", r.fileWrite)
|
fileObj.Set("write", r.fileWrite)
|
||||||
fileObj.Set("writeBytes", r.fileWriteBytes)
|
|
||||||
fileObj.Set("copy", r.fileCopy)
|
fileObj.Set("copy", r.fileCopy)
|
||||||
fileObj.Set("move", r.fileMove)
|
fileObj.Set("move", r.fileMove)
|
||||||
fileObj.Set("getSize", r.fileGetSize)
|
fileObj.Set("getSize", r.fileGetSize)
|
||||||
@@ -541,17 +407,8 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
||||||
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
||||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||||
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
|
|
||||||
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
|
|
||||||
utilsObj.Set("decryptCTRSegments", r.decryptCTRSegments)
|
|
||||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||||
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
||||||
utilsObj.Set("appVersion", r.appVersion)
|
|
||||||
utilsObj.Set("appUserAgent", r.appUserAgent)
|
|
||||||
utilsObj.Set("sleep", r.sleep)
|
|
||||||
utilsObj.Set("isDownloadCancelled", r.isDownloadCancelled)
|
|
||||||
utilsObj.Set("isRequestCancelled", r.isRequestCancelled)
|
|
||||||
utilsObj.Set("setDownloadStatus", r.setDownloadStatus)
|
|
||||||
vm.Set("utils", utilsObj)
|
vm.Set("utils", utilsObj)
|
||||||
|
|
||||||
logObj := vm.NewObject()
|
logObj := vm.NewObject()
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func summarizeURLForLog(urlStr string) string {
|
|||||||
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -99,7 +99,7 @@ func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ func (r *extensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(state.AuthCode)
|
return r.vm.ToValue(state.AuthCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -149,7 +149,7 @@ func (r *extensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.Lock()
|
extensionAuthStateMu.Lock()
|
||||||
delete(extensionAuthState, r.extensionID)
|
delete(extensionAuthState, r.extensionID)
|
||||||
extensionAuthStateMu.Unlock()
|
extensionAuthStateMu.Unlock()
|
||||||
@@ -162,7 +162,7 @@ func (r *extensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ func (r *extensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
|
|||||||
return r.vm.ToValue(state.IsAuthenticated)
|
return r.vm.ToValue(state.IsAuthenticated)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
@@ -228,7 +228,7 @@ func generatePKCEChallenge(verifier string) string {
|
|||||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||||
length := 64
|
length := 64
|
||||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
||||||
@@ -265,7 +265,7 @@ func (r *extensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||||
extensionAuthStateMu.RLock()
|
extensionAuthStateMu.RLock()
|
||||||
defer extensionAuthStateMu.RUnlock()
|
defer extensionAuthStateMu.RUnlock()
|
||||||
|
|
||||||
@@ -281,7 +281,7 @@ func (r *extensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -385,7 +385,7 @@ func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -458,10 +458,9 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
|||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
req = r.bindDownloadCancelContext(req)
|
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.Header.Set("User-Agent", appUserAgent())
|
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||||
|
|
||||||
resp, err := r.httpClient.Do(req)
|
resp, err := r.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,534 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
|
||||||
//lint:ignore SA1019 Blowfish is required for legacy extension crypto compatibility.
|
|
||||||
"golang.org/x/crypto/blowfish"
|
|
||||||
)
|
|
||||||
|
|
||||||
type runtimeBlockCipherOptions struct {
|
|
||||||
Algorithm string
|
|
||||||
Mode string
|
|
||||||
Key []byte
|
|
||||||
IV []byte
|
|
||||||
InputEncoding string
|
|
||||||
OutputEncoding string
|
|
||||||
Padding string
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseRuntimeOptionsArgument(call goja.FunctionCall, index int) map[string]interface{} {
|
|
||||||
if len(call.Arguments) <= index {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
value := call.Arguments[index]
|
|
||||||
if goja.IsUndefined(value) || goja.IsNull(value) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
exported := value.Export()
|
|
||||||
if options, ok := exported.(map[string]interface{}); ok {
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runtimeOptionString(options map[string]interface{}, key, defaultValue string) string {
|
|
||||||
if options == nil {
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
raw, ok := options[key]
|
|
||||||
if !ok || raw == nil {
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
switch value := raw.(type) {
|
|
||||||
case string:
|
|
||||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
case []byte:
|
|
||||||
if len(value) > 0 {
|
|
||||||
return string(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
|
|
||||||
func runtimeOptionBool(options map[string]interface{}, key string, defaultValue bool) bool {
|
|
||||||
if options == nil {
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
raw, ok := options[key]
|
|
||||||
if !ok || raw == nil {
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
switch value := raw.(type) {
|
|
||||||
case bool:
|
|
||||||
return value
|
|
||||||
case int:
|
|
||||||
return value != 0
|
|
||||||
case int64:
|
|
||||||
return value != 0
|
|
||||||
case float64:
|
|
||||||
return value != 0
|
|
||||||
case string:
|
|
||||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
||||||
case "1", "true", "yes", "on":
|
|
||||||
return true
|
|
||||||
case "0", "false", "no", "off":
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
|
|
||||||
func runtimeOptionInt64(options map[string]interface{}, key string, defaultValue int64) int64 {
|
|
||||||
if options == nil {
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
raw, ok := options[key]
|
|
||||||
if !ok || raw == nil {
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
switch value := raw.(type) {
|
|
||||||
case int:
|
|
||||||
return int64(value)
|
|
||||||
case int32:
|
|
||||||
return int64(value)
|
|
||||||
case int64:
|
|
||||||
return value
|
|
||||||
case float32:
|
|
||||||
return int64(value)
|
|
||||||
case float64:
|
|
||||||
return int64(value)
|
|
||||||
case string:
|
|
||||||
value = strings.TrimSpace(value)
|
|
||||||
if value == "" {
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
var parsed int64
|
|
||||||
if _, err := fmt.Sscanf(value, "%d", &parsed); err == nil {
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return defaultValue
|
|
||||||
}
|
|
||||||
|
|
||||||
func runtimeOptionHasKey(options map[string]interface{}, key string) bool {
|
|
||||||
if options == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, exists := options[key]
|
|
||||||
return exists
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeRuntimeBytesString(input, encoding string) ([]byte, error) {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
|
||||||
case "", "utf8", "utf-8", "text":
|
|
||||||
return []byte(input), nil
|
|
||||||
case "base64":
|
|
||||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(input))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid base64 data: %w", err)
|
|
||||||
}
|
|
||||||
return decoded, nil
|
|
||||||
case "hex":
|
|
||||||
decoded, err := hex.DecodeString(strings.TrimSpace(input))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid hex data: %w", err)
|
|
||||||
}
|
|
||||||
return decoded, nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported byte encoding: %s", encoding)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
|
|
||||||
switch value := raw.(type) {
|
|
||||||
case string:
|
|
||||||
return decodeRuntimeBytesString(value, encoding)
|
|
||||||
case []byte:
|
|
||||||
cloned := make([]byte, len(value))
|
|
||||||
copy(cloned, value)
|
|
||||||
return cloned, nil
|
|
||||||
case goja.ArrayBuffer:
|
|
||||||
src := value.Bytes()
|
|
||||||
cloned := make([]byte, len(src))
|
|
||||||
copy(cloned, src)
|
|
||||||
return cloned, nil
|
|
||||||
case []interface{}:
|
|
||||||
decoded := make([]byte, len(value))
|
|
||||||
for i, item := range value {
|
|
||||||
switch num := item.(type) {
|
|
||||||
case int:
|
|
||||||
decoded[i] = byte(num)
|
|
||||||
case int64:
|
|
||||||
decoded[i] = byte(num)
|
|
||||||
case float64:
|
|
||||||
decoded[i] = byte(int(num))
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported byte array item at index %d", i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return decoded, nil
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported byte payload type")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func encodeRuntimeBytes(data []byte, encoding string) (string, error) {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
|
||||||
case "", "base64":
|
|
||||||
return base64.StdEncoding.EncodeToString(data), nil
|
|
||||||
case "hex":
|
|
||||||
return hex.EncodeToString(data), nil
|
|
||||||
case "utf8", "utf-8", "text":
|
|
||||||
return string(data), nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("unsupported byte encoding: %s", encoding)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseRuntimeBlockCipherOptions(options map[string]interface{}) (*runtimeBlockCipherOptions, error) {
|
|
||||||
parsed := &runtimeBlockCipherOptions{
|
|
||||||
Algorithm: strings.ToLower(runtimeOptionString(options, "algorithm", "")),
|
|
||||||
Mode: strings.ToLower(runtimeOptionString(options, "mode", "cbc")),
|
|
||||||
InputEncoding: strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64")),
|
|
||||||
OutputEncoding: strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64")),
|
|
||||||
Padding: strings.ToLower(runtimeOptionString(options, "padding", "none")),
|
|
||||||
}
|
|
||||||
if parsed.Algorithm == "" {
|
|
||||||
return nil, fmt.Errorf("algorithm is required")
|
|
||||||
}
|
|
||||||
if parsed.Mode == "" {
|
|
||||||
return nil, fmt.Errorf("mode is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
key, err := decodeRuntimeBytesString(runtimeOptionString(options, "key", ""), runtimeOptionString(options, "keyEncoding", "utf8"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid key: %w", err)
|
|
||||||
}
|
|
||||||
if len(key) == 0 {
|
|
||||||
return nil, fmt.Errorf("key is required")
|
|
||||||
}
|
|
||||||
parsed.Key = key
|
|
||||||
|
|
||||||
iv, err := decodeRuntimeBytesString(runtimeOptionString(options, "iv", ""), runtimeOptionString(options, "ivEncoding", "utf8"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid iv: %w", err)
|
|
||||||
}
|
|
||||||
parsed.IV = iv
|
|
||||||
return parsed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRuntimeBlockCipher(options *runtimeBlockCipherOptions) (cipher.Block, error) {
|
|
||||||
switch options.Algorithm {
|
|
||||||
case "blowfish":
|
|
||||||
return blowfish.NewCipher(options.Key)
|
|
||||||
case "aes":
|
|
||||||
return aes.NewCipher(options.Key)
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported block cipher algorithm: %s", options.Algorithm)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyPKCS7Padding(data []byte, blockSize int) []byte {
|
|
||||||
padding := blockSize - (len(data) % blockSize)
|
|
||||||
if padding == 0 {
|
|
||||||
padding = blockSize
|
|
||||||
}
|
|
||||||
out := make([]byte, len(data)+padding)
|
|
||||||
copy(out, data)
|
|
||||||
for i := len(data); i < len(out); i++ {
|
|
||||||
out[i] = byte(padding)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func removePKCS7Padding(data []byte, blockSize int) ([]byte, error) {
|
|
||||||
if len(data) == 0 || len(data)%blockSize != 0 {
|
|
||||||
return nil, fmt.Errorf("invalid padded payload length")
|
|
||||||
}
|
|
||||||
padding := int(data[len(data)-1])
|
|
||||||
if padding <= 0 || padding > blockSize || padding > len(data) {
|
|
||||||
return nil, fmt.Errorf("invalid PKCS7 padding")
|
|
||||||
}
|
|
||||||
for i := len(data) - padding; i < len(data); i++ {
|
|
||||||
if int(data[i]) != padding {
|
|
||||||
return nil, fmt.Errorf("invalid PKCS7 padding")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return data[:len(data)-padding], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt bool) goja.Value {
|
|
||||||
if len(call.Arguments) < 2 {
|
|
||||||
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.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
switch parsedOptions.Mode {
|
|
||||||
case "cbc", "ctr":
|
|
||||||
default:
|
|
||||||
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.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
block, err := newRuntimeBlockCipher(parsedOptions)
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(parsedOptions.IV) != block.BlockSize() {
|
|
||||||
ivLabel := "iv"
|
|
||||||
if parsedOptions.Mode == "ctr" {
|
|
||||||
ivLabel = "iv (counter)"
|
|
||||||
}
|
|
||||||
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
|
|
||||||
if parsedOptions.Mode == "ctr" {
|
|
||||||
// CTR is a stream mode: encryption and decryption are identical,
|
|
||||||
// require no padding, and accept arbitrary input lengths.
|
|
||||||
output = make([]byte, len(inputData))
|
|
||||||
cipher.NewCTR(block, parsedOptions.IV).XORKeyStream(output, inputData)
|
|
||||||
} else {
|
|
||||||
data := inputData
|
|
||||||
if !decrypt && parsedOptions.Padding == "pkcs7" {
|
|
||||||
data = applyPKCS7Padding(data, block.BlockSize())
|
|
||||||
}
|
|
||||||
if len(data)%block.BlockSize() != 0 {
|
|
||||||
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))
|
|
||||||
if decrypt {
|
|
||||||
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
|
||||||
if parsedOptions.Padding == "pkcs7" {
|
|
||||||
output, err = removePKCS7Padding(output, block.BlockSize())
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding)
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"data": encoded,
|
|
||||||
"block_size": block.BlockSize(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *extensionRuntime) encryptBlockCipher(call goja.FunctionCall) goja.Value {
|
|
||||||
return r.transformBlockCipher(call, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value {
|
|
||||||
return r.transformBlockCipher(call, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// decryptCTRSegments decrypts many independently-IV'd AES-CTR segments inside a
|
|
||||||
// single buffer in one host call. This exists to avoid thousands of JS->Go
|
|
||||||
// bridge crossings when an extension decrypts per-sample CENC media (each
|
|
||||||
// sample has its own IV/counter and cannot be merged into one stream).
|
|
||||||
//
|
|
||||||
// It is a generic primitive: any extension can use it for "one buffer, many
|
|
||||||
// CTR segments" workloads, not just Apple CENC.
|
|
||||||
//
|
|
||||||
// For best performance, pass the buffer as an ArrayBuffer/Uint8Array and set
|
|
||||||
// outputEncoding:"bytes" to get an ArrayBuffer back. This avoids base64
|
|
||||||
// encode/decode of the (potentially multi-MB) payload entirely, which is the
|
|
||||||
// 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
|
|
||||||
// })
|
|
||||||
// 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.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": msg,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(call.Arguments) < 2 {
|
|
||||||
return fail("data and options are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
options := parseRuntimeOptionsArgument(call, 1)
|
|
||||||
if options == nil {
|
|
||||||
return fail("options object is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
algorithm := strings.ToLower(runtimeOptionString(options, "algorithm", "aes"))
|
|
||||||
inputEncoding := strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64"))
|
|
||||||
outputEncoding := strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64"))
|
|
||||||
ivEncoding := strings.ToLower(runtimeOptionString(options, "ivEncoding", "base64"))
|
|
||||||
|
|
||||||
key, err := decodeRuntimeBytesString(
|
|
||||||
runtimeOptionString(options, "key", ""),
|
|
||||||
runtimeOptionString(options, "keyEncoding", "hex"),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fail(fmt.Sprintf("invalid key: %v", err))
|
|
||||||
}
|
|
||||||
if len(key) == 0 {
|
|
||||||
return fail("key is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
var block cipher.Block
|
|
||||||
switch algorithm {
|
|
||||||
case "aes":
|
|
||||||
block, err = aes.NewCipher(key)
|
|
||||||
case "blowfish":
|
|
||||||
block, err = blowfish.NewCipher(key)
|
|
||||||
default:
|
|
||||||
return fail("unsupported algorithm: " + algorithm)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return fail(err.Error())
|
|
||||||
}
|
|
||||||
blockSize := block.BlockSize()
|
|
||||||
|
|
||||||
// Decode the payload. For "bytes" input we operate on the raw []byte
|
|
||||||
// (ArrayBuffer/Uint8Array) without any base64 round-trip.
|
|
||||||
var data []byte
|
|
||||||
if inputEncoding == "bytes" || inputEncoding == "raw" {
|
|
||||||
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), "")
|
|
||||||
if err != nil {
|
|
||||||
return fail("invalid byte payload: " + err.Error())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), inputEncoding)
|
|
||||||
if err != nil {
|
|
||||||
return fail(err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rawSegments, ok := options["segments"]
|
|
||||||
if !ok || rawSegments == nil {
|
|
||||||
return fail("segments array is required")
|
|
||||||
}
|
|
||||||
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]interface{})
|
|
||||||
if !ok {
|
|
||||||
return fail(fmt.Sprintf("segment %d is not an object", i))
|
|
||||||
}
|
|
||||||
|
|
||||||
offset := int(runtimeOptionInt64(seg, "offset", -1))
|
|
||||||
size := int(runtimeOptionInt64(seg, "size", -1))
|
|
||||||
if offset < 0 || size < 0 {
|
|
||||||
return fail(fmt.Sprintf("segment %d has invalid offset/size", i))
|
|
||||||
}
|
|
||||||
if size == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if offset+size > len(data) {
|
|
||||||
return fail(fmt.Sprintf("segment %d out of bounds (offset=%d size=%d len=%d)", i, offset, size, len(data)))
|
|
||||||
}
|
|
||||||
|
|
||||||
iv, err := decodeRuntimeBytesString(runtimeOptionString(seg, "iv", ""), ivEncoding)
|
|
||||||
if err != nil {
|
|
||||||
return fail(fmt.Sprintf("segment %d has invalid iv: %v", i, err))
|
|
||||||
}
|
|
||||||
if len(iv) != blockSize {
|
|
||||||
// Accept short IVs by left-aligning into a block-sized counter
|
|
||||||
// (CENC commonly uses 8-byte IVs for a 16-byte AES counter).
|
|
||||||
if len(iv) > blockSize {
|
|
||||||
return fail(fmt.Sprintf("segment %d iv longer than block size (%d > %d)", i, len(iv), blockSize))
|
|
||||||
}
|
|
||||||
padded := make([]byte, blockSize)
|
|
||||||
copy(padded, iv)
|
|
||||||
iv = padded
|
|
||||||
}
|
|
||||||
|
|
||||||
segData := data[offset : offset+size]
|
|
||||||
cipher.NewCTR(block, iv).XORKeyStream(segData, segData)
|
|
||||||
processed++
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"data": r.vm.NewArrayBuffer(data),
|
|
||||||
"segments_processed": processed,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded, err := encodeRuntimeBytes(data, outputEncoding)
|
|
||||||
if err != nil {
|
|
||||||
return fail(err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"data": encoded,
|
|
||||||
"segments_processed": processed,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,485 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newBinaryTestRuntime(t *testing.T, withFilePermission bool) *goja.Runtime {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ext := &loadedExtension{
|
|
||||||
ID: "binary-test-ext",
|
|
||||||
Manifest: &ExtensionManifest{
|
|
||||||
Name: "binary-test-ext",
|
|
||||||
Permissions: ExtensionPermissions{
|
|
||||||
File: withFilePermission,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime := newExtensionRuntime(ext)
|
|
||||||
vm := goja.New()
|
|
||||||
runtime.RegisterAPIs(vm)
|
|
||||||
return vm
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeJSONResult[T any](t *testing.T, value goja.Value) T {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var decoded T
|
|
||||||
if err := json.Unmarshal([]byte(value.String()), &decoded); err != nil {
|
|
||||||
t.Fatalf("failed to decode JSON result: %v", err)
|
|
||||||
}
|
|
||||||
return decoded
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtensionRuntime_FileByteAPIs(t *testing.T) {
|
|
||||||
vm := newBinaryTestRuntime(t, true)
|
|
||||||
|
|
||||||
result, err := vm.RunString(`
|
|
||||||
(function() {
|
|
||||||
var first = file.writeBytes("bytes.bin", "AAEC", {encoding: "base64", truncate: true});
|
|
||||||
if (!first.success) throw new Error(first.error);
|
|
||||||
|
|
||||||
var second = file.writeBytes("bytes.bin", "0304ff", {encoding: "hex", append: true});
|
|
||||||
if (!second.success) throw new Error(second.error);
|
|
||||||
|
|
||||||
var all = file.readBytes("bytes.bin", {encoding: "hex"});
|
|
||||||
if (!all.success) throw new Error(all.error);
|
|
||||||
|
|
||||||
var slice = file.readBytes("bytes.bin", {offset: 2, length: 2, encoding: "hex"});
|
|
||||||
if (!slice.success) throw new Error(slice.error);
|
|
||||||
|
|
||||||
var tail = file.readBytes("bytes.bin", {offset: 6, length: 4, encoding: "hex"});
|
|
||||||
if (!tail.success) throw new Error(tail.error);
|
|
||||||
|
|
||||||
return JSON.stringify({
|
|
||||||
all: all.data,
|
|
||||||
slice: slice.data,
|
|
||||||
size: all.size,
|
|
||||||
sliceBytes: slice.bytes_read,
|
|
||||||
sliceEof: slice.eof,
|
|
||||||
tailBytes: tail.bytes_read,
|
|
||||||
tailEof: tail.eof
|
|
||||||
});
|
|
||||||
})()
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("file byte APIs failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded := decodeJSONResult[struct {
|
|
||||||
All string `json:"all"`
|
|
||||||
Slice string `json:"slice"`
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
SliceBytes int `json:"sliceBytes"`
|
|
||||||
SliceEof bool `json:"sliceEof"`
|
|
||||||
TailBytes int `json:"tailBytes"`
|
|
||||||
TailEof bool `json:"tailEof"`
|
|
||||||
}](t, result)
|
|
||||||
|
|
||||||
if decoded.All != "0001020304ff" {
|
|
||||||
t.Fatalf("all = %q", decoded.All)
|
|
||||||
}
|
|
||||||
if decoded.Slice != "0203" {
|
|
||||||
t.Fatalf("slice = %q", decoded.Slice)
|
|
||||||
}
|
|
||||||
if decoded.Size != 6 {
|
|
||||||
t.Fatalf("size = %d", decoded.Size)
|
|
||||||
}
|
|
||||||
if decoded.SliceBytes != 2 {
|
|
||||||
t.Fatalf("slice bytes = %d", decoded.SliceBytes)
|
|
||||||
}
|
|
||||||
if decoded.SliceEof {
|
|
||||||
t.Fatal("slice should not be EOF")
|
|
||||||
}
|
|
||||||
if decoded.TailBytes != 0 || !decoded.TailEof {
|
|
||||||
t.Fatalf("tail read mismatch: bytes=%d eof=%v", decoded.TailBytes, decoded.TailEof)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtensionRuntime_BlockCipherCBCSupportsBlowfish(t *testing.T) {
|
|
||||||
vm := newBinaryTestRuntime(t, false)
|
|
||||||
|
|
||||||
result, err := vm.RunString(`
|
|
||||||
(function() {
|
|
||||||
var options = {
|
|
||||||
algorithm: "blowfish",
|
|
||||||
mode: "cbc",
|
|
||||||
key: "0123456789ABCDEFF0E1D2C3B4A59687",
|
|
||||||
keyEncoding: "hex",
|
|
||||||
iv: "0001020304050607",
|
|
||||||
ivEncoding: "hex",
|
|
||||||
inputEncoding: "hex",
|
|
||||||
outputEncoding: "hex",
|
|
||||||
padding: "none"
|
|
||||||
};
|
|
||||||
var enc = utils.encryptBlockCipher("00112233445566778899aabbccddeeff", options);
|
|
||||||
if (!enc.success) throw new Error(enc.error);
|
|
||||||
var dec = utils.decryptBlockCipher(enc.data, options);
|
|
||||||
if (!dec.success) throw new Error(dec.error);
|
|
||||||
return JSON.stringify({enc: enc.data, dec: dec.data});
|
|
||||||
})()
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("blowfish block cipher failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded := decodeJSONResult[struct {
|
|
||||||
Enc string `json:"enc"`
|
|
||||||
Dec string `json:"dec"`
|
|
||||||
}](t, result)
|
|
||||||
|
|
||||||
if decoded.Dec != "00112233445566778899aabbccddeeff" {
|
|
||||||
t.Fatalf("dec = %q", decoded.Dec)
|
|
||||||
}
|
|
||||||
if decoded.Enc == decoded.Dec {
|
|
||||||
t.Fatal("expected ciphertext to differ from plaintext")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtensionRuntime_BlockCipherCBCSupportsAES(t *testing.T) {
|
|
||||||
vm := newBinaryTestRuntime(t, false)
|
|
||||||
|
|
||||||
result, err := vm.RunString(`
|
|
||||||
(function() {
|
|
||||||
var options = {
|
|
||||||
algorithm: "aes",
|
|
||||||
mode: "cbc",
|
|
||||||
key: "000102030405060708090a0b0c0d0e0f",
|
|
||||||
keyEncoding: "hex",
|
|
||||||
iv: "0f0e0d0c0b0a09080706050403020100",
|
|
||||||
ivEncoding: "hex",
|
|
||||||
inputEncoding: "utf8",
|
|
||||||
outputEncoding: "base64",
|
|
||||||
padding: "pkcs7"
|
|
||||||
};
|
|
||||||
var enc = utils.encryptBlockCipher("hello generic cbc", options);
|
|
||||||
if (!enc.success) throw new Error(enc.error);
|
|
||||||
var dec = utils.decryptBlockCipher(enc.data, {
|
|
||||||
algorithm: "aes",
|
|
||||||
mode: "cbc",
|
|
||||||
key: options.key,
|
|
||||||
keyEncoding: options.keyEncoding,
|
|
||||||
iv: options.iv,
|
|
||||||
ivEncoding: options.ivEncoding,
|
|
||||||
inputEncoding: "base64",
|
|
||||||
outputEncoding: "utf8",
|
|
||||||
padding: "pkcs7"
|
|
||||||
});
|
|
||||||
if (!dec.success) throw new Error(dec.error);
|
|
||||||
return dec.data;
|
|
||||||
})()
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("aes block cipher failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.String() != "hello generic cbc" {
|
|
||||||
t.Fatalf("unexpected decrypted value: %q", result.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtensionRuntime_BlockCipherCTRSupportsAES(t *testing.T) {
|
|
||||||
vm := newBinaryTestRuntime(t, false)
|
|
||||||
|
|
||||||
// NIST SP 800-38A, F.5.1 CTR-AES128.Encrypt test vector.
|
|
||||||
// Key: 2b7e151628aed2a6abf7158809cf4f3c
|
|
||||||
// Counter: f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff
|
|
||||||
// Plaintext: 6bc1bee22e409f96e93d7e117393172a (block 1)
|
|
||||||
// Ciphertext: 874d6191b620e3261bef6864990db6ce (block 1)
|
|
||||||
result, err := vm.RunString(`
|
|
||||||
(function() {
|
|
||||||
var options = {
|
|
||||||
algorithm: "aes",
|
|
||||||
mode: "ctr",
|
|
||||||
key: "2b7e151628aed2a6abf7158809cf4f3c",
|
|
||||||
keyEncoding: "hex",
|
|
||||||
iv: "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff",
|
|
||||||
ivEncoding: "hex",
|
|
||||||
inputEncoding: "hex",
|
|
||||||
outputEncoding: "hex"
|
|
||||||
};
|
|
||||||
var enc = utils.encryptBlockCipher("6bc1bee22e409f96e93d7e117393172a", options);
|
|
||||||
if (!enc.success) throw new Error(enc.error);
|
|
||||||
// CTR is symmetric: decrypt is the same transform as encrypt.
|
|
||||||
var dec = utils.decryptBlockCipher(enc.data, options);
|
|
||||||
if (!dec.success) throw new Error(dec.error);
|
|
||||||
return JSON.stringify({enc: enc.data, dec: dec.data});
|
|
||||||
})()
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("aes ctr block cipher failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded := decodeJSONResult[struct {
|
|
||||||
Enc string `json:"enc"`
|
|
||||||
Dec string `json:"dec"`
|
|
||||||
}](t, result)
|
|
||||||
|
|
||||||
if decoded.Enc != "874d6191b620e3261bef6864990db6ce" {
|
|
||||||
t.Fatalf("ctr ciphertext = %q, want NIST vector 874d6191b620e3261bef6864990db6ce", decoded.Enc)
|
|
||||||
}
|
|
||||||
if decoded.Dec != "6bc1bee22e409f96e93d7e117393172a" {
|
|
||||||
t.Fatalf("ctr round-trip dec = %q", decoded.Dec)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtensionRuntime_BlockCipherCTRHandlesNonBlockLength(t *testing.T) {
|
|
||||||
vm := newBinaryTestRuntime(t, false)
|
|
||||||
|
|
||||||
// CTR is a stream mode, so arbitrary (non-16-byte-aligned) input lengths
|
|
||||||
// must round-trip without any padding.
|
|
||||||
result, err := vm.RunString(`
|
|
||||||
(function() {
|
|
||||||
var options = {
|
|
||||||
algorithm: "aes",
|
|
||||||
mode: "ctr",
|
|
||||||
key: "000102030405060708090a0b0c0d0e0f",
|
|
||||||
keyEncoding: "hex",
|
|
||||||
iv: "0f0e0d0c0b0a09080706050403020100",
|
|
||||||
ivEncoding: "hex",
|
|
||||||
inputEncoding: "utf8",
|
|
||||||
outputEncoding: "base64"
|
|
||||||
};
|
|
||||||
var enc = utils.encryptBlockCipher("stream ctr of odd length", options);
|
|
||||||
if (!enc.success) throw new Error(enc.error);
|
|
||||||
var dec = utils.decryptBlockCipher(enc.data, {
|
|
||||||
algorithm: "aes",
|
|
||||||
mode: "ctr",
|
|
||||||
key: options.key,
|
|
||||||
keyEncoding: options.keyEncoding,
|
|
||||||
iv: options.iv,
|
|
||||||
ivEncoding: options.ivEncoding,
|
|
||||||
inputEncoding: "base64",
|
|
||||||
outputEncoding: "utf8"
|
|
||||||
});
|
|
||||||
if (!dec.success) throw new Error(dec.error);
|
|
||||||
return dec.data;
|
|
||||||
})()
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("aes ctr stream length failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.String() != "stream ctr of odd length" {
|
|
||||||
t.Fatalf("unexpected ctr decrypted value: %q", result.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtensionRuntime_BlockCipherCTRRejectsBadIV(t *testing.T) {
|
|
||||||
vm := newBinaryTestRuntime(t, false)
|
|
||||||
|
|
||||||
result, err := vm.RunString(`
|
|
||||||
(function() {
|
|
||||||
var res = utils.encryptBlockCipher("00112233", {
|
|
||||||
algorithm: "aes",
|
|
||||||
mode: "ctr",
|
|
||||||
key: "000102030405060708090a0b0c0d0e0f",
|
|
||||||
keyEncoding: "hex",
|
|
||||||
iv: "0001",
|
|
||||||
ivEncoding: "hex",
|
|
||||||
inputEncoding: "hex",
|
|
||||||
outputEncoding: "hex"
|
|
||||||
});
|
|
||||||
return JSON.stringify({success: res.success, error: res.error || ""});
|
|
||||||
})()
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("aes ctr bad iv eval failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded := decodeJSONResult[struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}](t, result)
|
|
||||||
|
|
||||||
if decoded.Success {
|
|
||||||
t.Fatal("expected failure for undersized CTR iv")
|
|
||||||
}
|
|
||||||
if decoded.Error == "" {
|
|
||||||
t.Fatal("expected error message for undersized CTR iv")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtensionRuntime_DecryptCTRSegmentsMatchesPerSegment(t *testing.T) {
|
|
||||||
vm := newBinaryTestRuntime(t, false)
|
|
||||||
|
|
||||||
// Build a buffer of 3 segments encrypted with distinct 8-byte IVs (CENC
|
|
||||||
// style), then verify the batch primitive decrypts all of them in one call,
|
|
||||||
// matching what per-segment decryptBlockCipher would produce.
|
|
||||||
result, err := vm.RunString(`
|
|
||||||
(function() {
|
|
||||||
var keyHex = "000102030405060708090a0b0c0d0e0f";
|
|
||||||
function b64(bytes){return utils.base64Encode(utils.toHex ? bytes : bytes);}
|
|
||||||
|
|
||||||
// segment plaintexts (hex) and 8-byte IVs (hex)
|
|
||||||
var segs = [
|
|
||||||
{ pt: "11111111111111111111", iv: "0000000000000001" },
|
|
||||||
{ pt: "2222222222", iv: "0000000000000002" },
|
|
||||||
{ pt: "333333333333333333333333", iv: "00000000000000ff" }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Encrypt each segment individually using single-shot CTR with a
|
|
||||||
// 16-byte counter (8-byte iv left-aligned), producing ciphertext hex.
|
|
||||||
function ivToB64(ivHex){
|
|
||||||
// pad 8-byte hex iv to 16 bytes then base64
|
|
||||||
var full = ivHex + "00000000000000000000000000000000".slice(ivHex.length);
|
|
||||||
return utils.base64Encode(utils.hexToBytes ? utils.hexToBytes(full) : full);
|
|
||||||
}
|
|
||||||
|
|
||||||
var cipherHex = "";
|
|
||||||
var offsets = [];
|
|
||||||
var off = 0;
|
|
||||||
var ivB64s = [];
|
|
||||||
for (var i=0;i<segs.length;i++){
|
|
||||||
var ivFullHex = (segs[i].iv + "00000000000000000000000000000000").slice(0,32);
|
|
||||||
var enc = utils.encryptBlockCipher(segs[i].pt, {
|
|
||||||
algorithm:"aes", mode:"ctr", key:keyHex, keyEncoding:"hex",
|
|
||||||
iv: ivFullHex, ivEncoding:"hex",
|
|
||||||
inputEncoding:"hex", outputEncoding:"hex"
|
|
||||||
});
|
|
||||||
if(!enc.success) throw new Error("enc seg "+i+": "+enc.error);
|
|
||||||
cipherHex += enc.data;
|
|
||||||
var sz = segs[i].pt.length/2;
|
|
||||||
offsets.push({offset: off, size: sz, ivHex: ivFullHex});
|
|
||||||
off += sz;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now decrypt the whole concatenated buffer in ONE batch call.
|
|
||||||
var segments = offsets.map(function(o){
|
|
||||||
return { offset:o.offset, size:o.size, iv:o.ivHex };
|
|
||||||
});
|
|
||||||
var batch = utils.decryptCTRSegments(cipherHex, {
|
|
||||||
algorithm:"aes", key:keyHex, keyEncoding:"hex",
|
|
||||||
segments: segments, ivEncoding:"hex",
|
|
||||||
inputEncoding:"hex", outputEncoding:"hex"
|
|
||||||
});
|
|
||||||
if(!batch.success) throw new Error("batch: "+batch.error);
|
|
||||||
|
|
||||||
var expected = "";
|
|
||||||
for (var j=0;j<segs.length;j++) expected += segs[j].pt;
|
|
||||||
|
|
||||||
return JSON.stringify({
|
|
||||||
out: batch.data,
|
|
||||||
expected: expected,
|
|
||||||
processed: batch.segments_processed
|
|
||||||
});
|
|
||||||
})()
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("batch CTR eval failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded := decodeJSONResult[struct {
|
|
||||||
Out string `json:"out"`
|
|
||||||
Expected string `json:"expected"`
|
|
||||||
Processed int `json:"processed"`
|
|
||||||
}](t, result)
|
|
||||||
|
|
||||||
if decoded.Out != decoded.Expected {
|
|
||||||
t.Fatalf("batch decrypt mismatch:\n got=%s\nwant=%s", decoded.Out, decoded.Expected)
|
|
||||||
}
|
|
||||||
if decoded.Processed != 3 {
|
|
||||||
t.Fatalf("segments_processed = %d, want 3", decoded.Processed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtensionRuntime_DecryptCTRSegmentsRejectsOutOfBounds(t *testing.T) {
|
|
||||||
vm := newBinaryTestRuntime(t, false)
|
|
||||||
|
|
||||||
result, err := vm.RunString(`
|
|
||||||
(function() {
|
|
||||||
var res = utils.decryptCTRSegments("00112233", {
|
|
||||||
algorithm:"aes", key:"000102030405060708090a0b0c0d0e0f", keyEncoding:"hex",
|
|
||||||
inputEncoding:"hex", outputEncoding:"hex",
|
|
||||||
ivEncoding:"hex",
|
|
||||||
segments: [ { offset: 0, size: 99, iv: "00000000000000000000000000000000" } ]
|
|
||||||
});
|
|
||||||
return JSON.stringify({ success: res.success, error: res.error || "" });
|
|
||||||
})()
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("oob eval failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded := decodeJSONResult[struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}](t, result)
|
|
||||||
|
|
||||||
if decoded.Success {
|
|
||||||
t.Fatal("expected out-of-bounds segment to fail")
|
|
||||||
}
|
|
||||||
if decoded.Error == "" {
|
|
||||||
t.Fatal("expected error message for out-of-bounds segment")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtensionRuntime_DecryptCTRSegmentsRawBytes(t *testing.T) {
|
|
||||||
vm := newBinaryTestRuntime(t, false)
|
|
||||||
|
|
||||||
// Verify the zero-base64 path: pass an ArrayBuffer in, request bytes out,
|
|
||||||
// and confirm round-trip correctness against single-shot CTR.
|
|
||||||
result, err := vm.RunString(`
|
|
||||||
(function() {
|
|
||||||
var keyHex = "000102030405060708090a0b0c0d0e0f";
|
|
||||||
var ivFullHex = "0000000000000001" + "00000000000000000000000000000000".slice(16);
|
|
||||||
|
|
||||||
// Plaintext as a Uint8Array of 20 bytes.
|
|
||||||
var pt = new Uint8Array(20);
|
|
||||||
for (var i = 0; i < pt.length; i++) pt[i] = (i * 7 + 3) & 0xff;
|
|
||||||
|
|
||||||
// Encrypt single-shot to get ciphertext (hex output for clarity).
|
|
||||||
var ptHex = "";
|
|
||||||
for (var j = 0; j < pt.length; j++) { var h = pt[j].toString(16); ptHex += (h.length === 1 ? "0" : "") + h; }
|
|
||||||
var enc = utils.encryptBlockCipher(ptHex, {
|
|
||||||
algorithm:"aes", mode:"ctr", key:keyHex, keyEncoding:"hex",
|
|
||||||
iv: ivFullHex, ivEncoding:"hex", inputEncoding:"hex", outputEncoding:"base64"
|
|
||||||
});
|
|
||||||
if (!enc.success) throw new Error("enc: " + enc.error);
|
|
||||||
|
|
||||||
// Decode ciphertext base64 into a Uint8Array to feed the raw path.
|
|
||||||
var cipherBytes = utils.base64Decode ? null : null;
|
|
||||||
// Build ArrayBuffer from base64 via Uint8Array manually:
|
|
||||||
var b64 = enc.data;
|
|
||||||
var bin = (typeof atob === "function") ? null : null;
|
|
||||||
|
|
||||||
// Simpler: ask the host to give us bytes by decrypting nothing is hard,
|
|
||||||
// so just pass the base64 ciphertext through decryptCTRSegments using
|
|
||||||
// base64 input but bytes output, then re-run with bytes input.
|
|
||||||
var step1 = utils.decryptCTRSegments(b64, {
|
|
||||||
algorithm:"aes", key:keyHex, keyEncoding:"hex",
|
|
||||||
segments: [ { offset:0, size:20, iv: ivFullHex } ],
|
|
||||||
ivEncoding:"hex", inputEncoding:"base64", outputEncoding:"bytes"
|
|
||||||
});
|
|
||||||
if (!step1.success) throw new Error("step1: " + step1.error);
|
|
||||||
if (typeof step1.data === "string") throw new Error("expected ArrayBuffer output, got string");
|
|
||||||
|
|
||||||
var outArr = new Uint8Array(step1.data);
|
|
||||||
var outHex = "";
|
|
||||||
for (var k = 0; k < outArr.length; k++) { var hh = outArr[k].toString(16); outHex += (hh.length === 1 ? "0" : "") + hh; }
|
|
||||||
return JSON.stringify({ out: outHex, expected: ptHex, len: outArr.length });
|
|
||||||
})()
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("raw-bytes eval failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded := decodeJSONResult[struct {
|
|
||||||
Out string `json:"out"`
|
|
||||||
Expected string `json:"expected"`
|
|
||||||
Len int `json:"len"`
|
|
||||||
}](t, result)
|
|
||||||
|
|
||||||
if decoded.Out != decoded.Expected {
|
|
||||||
t.Fatalf("raw-bytes decrypt mismatch:\n got=%s\nwant=%s", decoded.Out, decoded.Expected)
|
|
||||||
}
|
|
||||||
if decoded.Len != 20 {
|
|
||||||
t.Fatalf("output length = %d, want 20", decoded.Len)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -50,7 +50,7 @@ func ClearFFmpegCommand(commandID string) {
|
|||||||
delete(ffmpegCommands, commandID)
|
delete(ffmpegCommands, commandID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -107,7 +107,7 @@ func (r *extensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -131,11 +131,10 @@ func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
|||||||
"sample_rate": quality.SampleRate,
|
"sample_rate": quality.SampleRate,
|
||||||
"total_samples": quality.TotalSamples,
|
"total_samples": quality.TotalSamples,
|
||||||
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
|
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
|
||||||
"codec": quality.Codec,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
@@ -72,7 +71,7 @@ func isPathWithinBase(baseDir, targetPath string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) validatePath(path string) (string, error) {
|
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||||
if !r.manifest.Permissions.File {
|
if !r.manifest.Permissions.File {
|
||||||
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
||||||
}
|
}
|
||||||
@@ -107,7 +106,7 @@ func (r *extensionRuntime) validatePath(path string) (string, error) {
|
|||||||
return absPath, nil
|
return absPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -135,9 +134,6 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
|
|
||||||
var onProgress goja.Callable
|
var onProgress goja.Callable
|
||||||
var headers map[string]string
|
var headers map[string]string
|
||||||
var chunkedDownload bool
|
|
||||||
trackItemBytes := true
|
|
||||||
var chunkSize int64
|
|
||||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||||
optionsObj := call.Arguments[2].Export()
|
optionsObj := call.Arguments[2].Export()
|
||||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||||
@@ -152,39 +148,9 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
onProgress = callable
|
onProgress = callable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if trackBytes, ok := opts["trackItemBytes"]; ok {
|
|
||||||
if v, ok := trackBytes.(bool); ok {
|
|
||||||
trackItemBytes = v
|
|
||||||
}
|
|
||||||
} else if trackBytes, ok := opts["track_item_bytes"]; ok {
|
|
||||||
if v, ok := trackBytes.(bool); ok {
|
|
||||||
trackItemBytes = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if chunked, ok := opts["chunked"]; ok {
|
|
||||||
switch v := chunked.(type) {
|
|
||||||
case bool:
|
|
||||||
chunkedDownload = v
|
|
||||||
case int64:
|
|
||||||
if v > 0 {
|
|
||||||
chunkedDownload = true
|
|
||||||
chunkSize = v
|
|
||||||
}
|
|
||||||
case float64:
|
|
||||||
if v > 0 {
|
|
||||||
chunkedDownload = true
|
|
||||||
chunkSize = int64(v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default chunk size: 1MB (YouTube CDN max without poToken)
|
|
||||||
if chunkedDownload && chunkSize <= 0 {
|
|
||||||
chunkSize = 1024 * 1024
|
|
||||||
}
|
|
||||||
|
|
||||||
dir := filepath.Dir(fullPath)
|
dir := filepath.Dir(fullPath)
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -193,20 +159,6 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
client := r.downloadClient
|
|
||||||
if client == nil {
|
|
||||||
client = r.httpClient
|
|
||||||
}
|
|
||||||
|
|
||||||
ua := appUserAgent()
|
|
||||||
if h, ok := headers["User-Agent"]; ok && h != "" {
|
|
||||||
ua = h
|
|
||||||
}
|
|
||||||
|
|
||||||
if chunkedDownload {
|
|
||||||
return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress, trackItemBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", urlStr, nil)
|
req, err := http.NewRequest("GET", urlStr, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -214,13 +166,17 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
req = r.bindDownloadCancelContext(req)
|
|
||||||
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
if req.Header.Get("User-Agent") == "" {
|
if req.Header.Get("User-Agent") == "" {
|
||||||
req.Header.Set("User-Agent", appUserAgent())
|
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := r.downloadClient
|
||||||
|
if client == nil {
|
||||||
|
client = r.httpClient
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
@@ -232,7 +188,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode != 200 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": fmt.Sprintf("HTTP error: %d", resp.StatusCode),
|
"error": fmt.Sprintf("HTTP error: %d", resp.StatusCode),
|
||||||
@@ -248,19 +204,14 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
activeItemID := r.getActiveDownloadItemID()
|
|
||||||
if activeItemID != "" {
|
|
||||||
SetItemDownloading(activeItemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
contentLength := resp.ContentLength
|
contentLength := resp.ContentLength
|
||||||
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
|
activeItemID := r.getActiveDownloadItemID()
|
||||||
if shouldTrackItemBytes && contentLength > 0 {
|
if activeItemID != "" && contentLength > 0 {
|
||||||
SetItemBytesTotal(activeItemID, contentLength)
|
SetItemBytesTotal(activeItemID, contentLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
var progressWriter interface{ Write([]byte) (int, error) } = out
|
var progressWriter interface{ Write([]byte) (int, error) } = out
|
||||||
if shouldTrackItemBytes {
|
if activeItemID != "" {
|
||||||
progressWriter = NewItemProgressWriter(out, activeItemID)
|
progressWriter = NewItemProgressWriter(out, activeItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,14 +262,6 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if shouldTrackItemBytes {
|
|
||||||
if contentLength > 0 {
|
|
||||||
SetItemProgress(activeItemID, float64(written)/float64(contentLength), written, contentLength)
|
|
||||||
} else if written > 0 {
|
|
||||||
SetItemBytesReceived(activeItemID, written)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
|
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
|
||||||
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -328,237 +271,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileDownloadChunked downloads a URL using sequential Range requests.
|
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||||
// This is needed for servers (like YouTube's googlevideo CDN) that reject
|
|
||||||
// non-ranged or large-range requests with 403 and require small chunk downloads.
|
|
||||||
func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable, trackItemBytes bool) goja.Value {
|
|
||||||
// First, get the total content length with a small probe request
|
|
||||||
probeReq, err := http.NewRequest("GET", urlStr, nil)
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("chunked: probe request error: %v", err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
probeReq = r.bindDownloadCancelContext(probeReq)
|
|
||||||
probeReq.Header.Set("User-Agent", ua)
|
|
||||||
for k, v := range headers {
|
|
||||||
if k != "Range" { // Don't copy any existing Range header
|
|
||||||
probeReq.Header.Set(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
probeReq.Header.Set("Range", "bytes=0-1")
|
|
||||||
|
|
||||||
probeResp, err := client.Do(probeReq)
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("chunked: probe error: %v", err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
io.Copy(io.Discard, probeResp.Body)
|
|
||||||
probeResp.Body.Close()
|
|
||||||
|
|
||||||
if probeResp.StatusCode != 206 && probeResp.StatusCode != 200 {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("chunked: probe HTTP %d", probeResp.StatusCode),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse Content-Range to get total size: "bytes 0-1/TOTAL"
|
|
||||||
var totalSize int64
|
|
||||||
contentRange := probeResp.Header.Get("Content-Range")
|
|
||||||
if contentRange != "" {
|
|
||||||
if idx := strings.LastIndex(contentRange, "/"); idx >= 0 {
|
|
||||||
sizeStr := contentRange[idx+1:]
|
|
||||||
if sizeStr != "*" {
|
|
||||||
fmt.Sscanf(sizeStr, "%d", &totalSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if totalSize <= 0 {
|
|
||||||
// Fallback: try Content-Length from a HEAD-like approach
|
|
||||||
// If we can't determine size, download with unknown size
|
|
||||||
GoLog("[Extension:%s] Chunked download: unknown total size, will download until server says done\n", r.extensionID)
|
|
||||||
} else {
|
|
||||||
GoLog("[Extension:%s] Chunked download: total size %d bytes, chunk size %d\n", r.extensionID, totalSize, chunkSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := os.Create(fullPath)
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("failed to create file: %v", err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
activeItemID := r.getActiveDownloadItemID()
|
|
||||||
if activeItemID != "" {
|
|
||||||
SetItemDownloading(activeItemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
|
|
||||||
if shouldTrackItemBytes && totalSize > 0 {
|
|
||||||
SetItemBytesTotal(activeItemID, totalSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
var progressWriter interface{ Write([]byte) (int, error) } = out
|
|
||||||
if shouldTrackItemBytes {
|
|
||||||
progressWriter = NewItemProgressWriter(out, activeItemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalWritten int64
|
|
||||||
buf := make([]byte, 32*1024)
|
|
||||||
maxRetries := 3
|
|
||||||
|
|
||||||
for offset := int64(0); totalSize <= 0 || offset < totalSize; {
|
|
||||||
end := offset + chunkSize - 1
|
|
||||||
if totalSize > 0 && end >= totalSize {
|
|
||||||
end = totalSize - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
var chunkResp *http.Response
|
|
||||||
var chunkErr error
|
|
||||||
|
|
||||||
for retry := 0; retry < maxRetries; retry++ {
|
|
||||||
chunkReq, err := http.NewRequest("GET", urlStr, nil)
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("chunked: request error at offset %d: %v", offset, err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
chunkReq = r.bindDownloadCancelContext(chunkReq)
|
|
||||||
chunkReq.Header.Set("User-Agent", ua)
|
|
||||||
for k, v := range headers {
|
|
||||||
if k != "Range" {
|
|
||||||
chunkReq.Header.Set(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
chunkReq.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", offset, end))
|
|
||||||
|
|
||||||
chunkResp, chunkErr = client.Do(chunkReq)
|
|
||||||
if chunkErr != nil {
|
|
||||||
if retry < maxRetries-1 {
|
|
||||||
time.Sleep(time.Duration(retry+1) * time.Second)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("chunked: error at offset %d after %d retries: %v", offset, maxRetries, chunkErr),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if chunkResp.StatusCode == 206 || chunkResp.StatusCode == 200 {
|
|
||||||
break // Success
|
|
||||||
}
|
|
||||||
|
|
||||||
io.Copy(io.Discard, chunkResp.Body)
|
|
||||||
chunkResp.Body.Close()
|
|
||||||
|
|
||||||
if chunkResp.StatusCode == 403 || chunkResp.StatusCode == 429 {
|
|
||||||
if retry < maxRetries-1 {
|
|
||||||
time.Sleep(time.Duration(retry+1) * 2 * time.Second)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("chunked: HTTP %d at offset %d", chunkResp.StatusCode, offset),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
chunkWritten := int64(0)
|
|
||||||
for {
|
|
||||||
nr, er := chunkResp.Body.Read(buf)
|
|
||||||
if nr > 0 {
|
|
||||||
nw, ew := progressWriter.Write(buf[0:nr])
|
|
||||||
if nw < 0 || nr < nw {
|
|
||||||
nw = 0
|
|
||||||
if ew == nil {
|
|
||||||
ew = fmt.Errorf("invalid write result")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
chunkWritten += int64(nw)
|
|
||||||
totalWritten += int64(nw)
|
|
||||||
if ew != nil {
|
|
||||||
chunkResp.Body.Close()
|
|
||||||
if ew == ErrDownloadCancelled {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": "download cancelled",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("failed to write file: %v", ew),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if nr != nw {
|
|
||||||
chunkResp.Body.Close()
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": "short write",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if onProgress != nil && totalSize > 0 {
|
|
||||||
_, _ = onProgress(goja.Undefined(), r.vm.ToValue(totalWritten), r.vm.ToValue(totalSize))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if er != nil {
|
|
||||||
if er != io.EOF {
|
|
||||||
chunkResp.Body.Close()
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("failed to read chunk at offset %d: %v", offset, er),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
chunkResp.Body.Close()
|
|
||||||
|
|
||||||
offset += chunkWritten
|
|
||||||
|
|
||||||
// If server returned 200 (full content) instead of 206, we're done
|
|
||||||
if chunkResp.StatusCode == 200 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we got less data than expected and we know total size, check if done
|
|
||||||
if totalSize > 0 && offset >= totalSize {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unknown size: if we got less than chunk size, assume done
|
|
||||||
if totalSize <= 0 && chunkWritten < chunkSize {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if shouldTrackItemBytes {
|
|
||||||
if totalSize > 0 {
|
|
||||||
SetItemProgress(activeItemID, float64(totalWritten)/float64(totalSize), totalWritten, totalSize)
|
|
||||||
} else if totalWritten > 0 {
|
|
||||||
SetItemBytesReceived(activeItemID, totalWritten)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
GoLog("[Extension:%s] Chunked download complete: %d bytes to %s\n", r.extensionID, totalWritten, fullPath)
|
|
||||||
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"path": fullPath,
|
|
||||||
"size": totalWritten,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *extensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -573,7 +286,7 @@ func (r *extensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(err == nil)
|
return r.vm.ToValue(err == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -602,7 +315,7 @@ func (r *extensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -633,117 +346,7 @@ func (r *extensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": "path is required",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
path := call.Arguments[0].String()
|
|
||||||
fullPath, err := r.validatePath(path)
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
options := parseRuntimeOptionsArgument(call, 1)
|
|
||||||
offset := runtimeOptionInt64(options, "offset", 0)
|
|
||||||
length := runtimeOptionInt64(options, "length", -1)
|
|
||||||
encoding := runtimeOptionString(options, "encoding", "base64")
|
|
||||||
if offset < 0 {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": "offset must be >= 0",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
file, err := os.Open(fullPath)
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
info, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
size := info.Size()
|
|
||||||
if offset > size {
|
|
||||||
offset = size
|
|
||||||
}
|
|
||||||
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("failed to seek file: %v", err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var data []byte
|
|
||||||
switch {
|
|
||||||
case length == 0:
|
|
||||||
data = []byte{}
|
|
||||||
case length > 0:
|
|
||||||
buf := make([]byte, int(length))
|
|
||||||
n, readErr := file.Read(buf)
|
|
||||||
if readErr != nil && readErr != io.EOF {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("failed to read file: %v", readErr),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
data = buf[:n]
|
|
||||||
default:
|
|
||||||
data, err = io.ReadAll(file)
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("failed to read file: %v", err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.EqualFold(strings.TrimSpace(encoding), "bytes") ||
|
|
||||||
strings.EqualFold(strings.TrimSpace(encoding), "raw") {
|
|
||||||
// Return raw bytes as an ArrayBuffer to avoid base64 encode/decode of
|
|
||||||
// large payloads under the goja interpreter.
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"data": r.vm.NewArrayBuffer(data),
|
|
||||||
"bytes_read": len(data),
|
|
||||||
"offset": offset,
|
|
||||||
"size": size,
|
|
||||||
"eof": offset+int64(len(data)) >= size,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
encoded, err := encodeRuntimeBytes(data, encoding)
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"data": encoded,
|
|
||||||
"bytes_read": len(data),
|
|
||||||
"offset": offset,
|
|
||||||
"size": size,
|
|
||||||
"eof": offset+int64(len(data)) >= size,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -783,108 +386,7 @@ func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) fileWriteBytes(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": "path and data are required",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
path := call.Arguments[0].String()
|
|
||||||
fullPath, err := r.validatePath(path)
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
options := parseRuntimeOptionsArgument(call, 2)
|
|
||||||
appendMode := runtimeOptionBool(options, "append", false)
|
|
||||||
truncate := runtimeOptionBool(options, "truncate", false)
|
|
||||||
hasOffset := runtimeOptionHasKey(options, "offset")
|
|
||||||
offset := runtimeOptionInt64(options, "offset", 0)
|
|
||||||
encoding := runtimeOptionString(options, "encoding", "base64")
|
|
||||||
|
|
||||||
if appendMode && hasOffset {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": "append and offset cannot be used together",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if offset < 0 {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": "offset must be >= 0",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := decodeRuntimeBytesValue(call.Arguments[1].Export(), encoding)
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
dir := filepath.Dir(fullPath)
|
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
flags := os.O_CREATE | os.O_WRONLY
|
|
||||||
if appendMode {
|
|
||||||
flags |= os.O_APPEND
|
|
||||||
}
|
|
||||||
if truncate {
|
|
||||||
flags |= os.O_TRUNC
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.OpenFile(fullPath, flags, 0644)
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
if hasOffset && !appendMode {
|
|
||||||
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("failed to seek file: %v", err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
written, err := file.Write(data)
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
info, statErr := file.Stat()
|
|
||||||
size := int64(0)
|
|
||||||
if statErr == nil {
|
|
||||||
size = info.Size()
|
|
||||||
}
|
|
||||||
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
"path": fullPath,
|
|
||||||
"bytes_written": written,
|
|
||||||
"size": size,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *extensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -957,7 +459,7 @@ func (r *extensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -1005,7 +507,7 @@ func (r *extensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
|
|||||||
@@ -17,25 +17,7 @@ type HTTPResponse struct {
|
|||||||
Headers map[string]string `json:"headers"`
|
Headers map[string]string `json:"headers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxExtensionHTTPResponseBytes = 16 << 20
|
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||||
|
|
||||||
func readExtensionHTTPResponseBody(resp *http.Response) ([]byte, error) {
|
|
||||||
body, err := io.ReadAll(
|
|
||||||
io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes+1),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(body) > maxExtensionHTTPResponseBytes {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"response body exceeds %d byte limit; use file.download for large media",
|
|
||||||
maxExtensionHTTPResponseBytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return body, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *extensionRuntime) validateDomain(urlStr string) error {
|
|
||||||
parsed, err := url.Parse(urlStr)
|
parsed, err := url.Parse(urlStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid URL: %w", err)
|
return fmt.Errorf("invalid URL: %w", err)
|
||||||
@@ -44,8 +26,7 @@ func (r *extensionRuntime) validateDomain(urlStr string) error {
|
|||||||
if parsed.Scheme == "" {
|
if parsed.Scheme == "" {
|
||||||
return fmt.Errorf("invalid URL: scheme is required")
|
return fmt.Errorf("invalid URL: scheme is required")
|
||||||
}
|
}
|
||||||
if parsed.Scheme != "https" &&
|
if parsed.Scheme != "https" {
|
||||||
!(parsed.Scheme == "http" && r.manifest.Permissions.AllowHTTP) {
|
|
||||||
return fmt.Errorf("network access denied: only https is allowed")
|
return fmt.Errorf("network access denied: only https is allowed")
|
||||||
}
|
}
|
||||||
if parsed.User != nil {
|
if parsed.User != nil {
|
||||||
@@ -68,7 +49,7 @@ func (r *extensionRuntime) validateDomain(urlStr string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": "URL is required",
|
"error": "URL is required",
|
||||||
@@ -100,7 +81,6 @@ func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
|||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
req = r.bindDownloadCancelContext(req)
|
|
||||||
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
@@ -118,7 +98,7 @@ func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := readExtensionHTTPResponseBody(resp)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
@@ -144,7 +124,7 @@ func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": "URL is required",
|
"error": "URL is required",
|
||||||
@@ -195,7 +175,6 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
req = r.bindDownloadCancelContext(req)
|
|
||||||
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
@@ -216,7 +195,7 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := readExtensionHTTPResponseBody(resp)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
@@ -242,7 +221,7 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": "URL is required",
|
"error": "URL is required",
|
||||||
@@ -305,7 +284,6 @@ func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
req = r.bindDownloadCancelContext(req)
|
|
||||||
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
@@ -326,7 +304,7 @@ func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := readExtensionHTTPResponseBody(resp)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
@@ -352,19 +330,19 @@ func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||||
return r.httpMethodShortcut("PUT", call)
|
return r.httpMethodShortcut("PUT", call)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||||
return r.httpMethodShortcut("DELETE", call)
|
return r.httpMethodShortcut("DELETE", call)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||||
return r.httpMethodShortcut("PATCH", call)
|
return r.httpMethodShortcut("PATCH", call)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": "URL is required",
|
"error": "URL is required",
|
||||||
@@ -432,7 +410,6 @@ func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
req = r.bindDownloadCancelContext(req)
|
|
||||||
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
@@ -452,7 +429,7 @@ func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := readExtensionHTTPResponseBody(resp)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
@@ -478,7 +455,7 @@ func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||||
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
||||||
jar.mu.Lock()
|
jar.mu.Lock()
|
||||||
jar.cookies = make(map[string][]*http.Cookie)
|
jar.cookies = make(map[string][]*http.Cookie)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *extensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(0.0)
|
return r.vm.ToValue(0.0)
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ func (r *extensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.V
|
|||||||
return r.vm.ToValue(similarity)
|
return r.vm.ToValue(similarity)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ func (r *extensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
|
|||||||
return r.vm.ToValue(diff <= tolerance)
|
return r.vm.ToValue(diff <= tolerance)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.createFetchError("URL is required")
|
return r.createFetchError("URL is required")
|
||||||
}
|
}
|
||||||
@@ -69,13 +69,12 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return r.createFetchError(err.Error())
|
return r.createFetchError(err.Error())
|
||||||
}
|
}
|
||||||
req = r.bindDownloadCancelContext(req)
|
|
||||||
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
if req.Header.Get("User-Agent") == "" {
|
if req.Header.Get("User-Agent") == "" {
|
||||||
req.Header.Set("User-Agent", appUserAgent())
|
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||||
}
|
}
|
||||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
@@ -134,7 +133,7 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return responseObj
|
return responseObj
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) createFetchError(message string) goja.Value {
|
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
||||||
errorObj := r.vm.NewObject()
|
errorObj := r.vm.NewObject()
|
||||||
errorObj.Set("ok", false)
|
errorObj.Set("ok", false)
|
||||||
errorObj.Set("status", 0)
|
errorObj.Set("status", 0)
|
||||||
@@ -149,7 +148,7 @@ func (r *extensionRuntime) createFetchError(message string) goja.Value {
|
|||||||
return errorObj
|
return errorObj
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -165,7 +164,7 @@ func (r *extensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(string(decoded))
|
return r.vm.ToValue(string(decoded))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -173,7 +172,7 @@ func (r *extensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||||
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||||
encoder := call.This
|
encoder := call.This
|
||||||
encoder.Set("encoding", "utf-8")
|
encoder.Set("encoding", "utf-8")
|
||||||
@@ -253,7 +252,7 @@ func (r *extensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) registerURLClass(vm *goja.Runtime) {
|
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||||
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
|
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
|
||||||
urlObj := call.This
|
urlObj := call.This
|
||||||
|
|
||||||
@@ -417,7 +416,7 @@ func (r *extensionRuntime) registerURLClass(vm *goja.Runtime) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||||
jsonScript := `
|
jsonScript := `
|
||||||
if (typeof JSON === 'undefined') {
|
if (typeof JSON === 'undefined') {
|
||||||
var JSON = {
|
var JSON = {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const (
|
|||||||
storageFlushRetryDelay = 2 * time.Second
|
storageFlushRetryDelay = 2 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *extensionRuntime) getStoragePath() string {
|
func (r *ExtensionRuntime) getStoragePath() string {
|
||||||
return filepath.Join(r.dataDir, "storage.json")
|
return filepath.Join(r.dataDir, "storage.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} {
|
|||||||
return dst
|
return dst
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) ensureStorageLoaded() error {
|
func (r *ExtensionRuntime) ensureStorageLoaded() error {
|
||||||
r.storageMu.RLock()
|
r.storageMu.RLock()
|
||||||
if r.storageLoaded {
|
if r.storageLoaded {
|
||||||
r.storageMu.RUnlock()
|
r.storageMu.RUnlock()
|
||||||
@@ -74,7 +74,7 @@ func (r *extensionRuntime) ensureStorageLoaded() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) loadStorage() (map[string]interface{}, error) {
|
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||||
if err := r.ensureStorageLoaded(); err != nil {
|
if err := r.ensureStorageLoaded(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@ func (r *extensionRuntime) loadStorage() (map[string]interface{}, error) {
|
|||||||
return cloneInterfaceMap(r.storageCache), nil
|
return cloneInterfaceMap(r.storageCache), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
||||||
if r.storageClosed {
|
if r.storageClosed {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ func (r *extensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
|||||||
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
|
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
|
func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
|
||||||
data, err := json.Marshal(storage)
|
data, err := json.Marshal(storage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -106,13 +106,13 @@ func (r *extensionRuntime) persistStorageSnapshot(storage map[string]interface{}
|
|||||||
return os.WriteFile(r.getStoragePath(), data, 0600)
|
return os.WriteFile(r.getStoragePath(), data, 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) flushStorageDirtyAsync() {
|
func (r *ExtensionRuntime) flushStorageDirtyAsync() {
|
||||||
if err := r.flushStorageDirty(); err != nil {
|
if err := r.flushStorageDirty(); err != nil {
|
||||||
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
|
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) flushStorageDirty() error {
|
func (r *ExtensionRuntime) flushStorageDirty() error {
|
||||||
r.storageMu.Lock()
|
r.storageMu.Lock()
|
||||||
if r.storageClosed {
|
if r.storageClosed {
|
||||||
r.storageTimer = nil
|
r.storageTimer = nil
|
||||||
@@ -140,7 +140,7 @@ func (r *extensionRuntime) flushStorageDirty() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) flushStorageNow() error {
|
func (r *ExtensionRuntime) flushStorageNow() error {
|
||||||
r.storageMu.Lock()
|
r.storageMu.Lock()
|
||||||
if r.storageTimer != nil {
|
if r.storageTimer != nil {
|
||||||
r.storageTimer.Stop()
|
r.storageTimer.Stop()
|
||||||
@@ -157,7 +157,7 @@ func (r *extensionRuntime) flushStorageNow() error {
|
|||||||
return r.persistStorageSnapshot(snapshot)
|
return r.persistStorageSnapshot(snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) closeStorageFlusher() {
|
func (r *ExtensionRuntime) closeStorageFlusher() {
|
||||||
r.storageMu.Lock()
|
r.storageMu.Lock()
|
||||||
r.storageClosed = true
|
r.storageClosed = true
|
||||||
r.storageDirty = false
|
r.storageDirty = false
|
||||||
@@ -168,7 +168,7 @@ func (r *extensionRuntime) closeStorageFlusher() {
|
|||||||
r.storageMu.Unlock()
|
r.storageMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
@@ -193,7 +193,7 @@ func (r *extensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(value)
|
return r.vm.ToValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -225,7 +225,7 @@ func (r *extensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -254,15 +254,15 @@ func (r *extensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) getCredentialsPath() string {
|
func (r *ExtensionRuntime) getCredentialsPath() string {
|
||||||
return filepath.Join(r.dataDir, ".credentials.enc")
|
return filepath.Join(r.dataDir, ".credentials.enc")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) getSaltPath() string {
|
func (r *ExtensionRuntime) getSaltPath() string {
|
||||||
return filepath.Join(r.dataDir, ".cred_salt")
|
return filepath.Join(r.dataDir, ".cred_salt")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
|
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||||
saltPath := r.getSaltPath()
|
saltPath := r.getSaltPath()
|
||||||
|
|
||||||
salt, err := os.ReadFile(saltPath)
|
salt, err := os.ReadFile(saltPath)
|
||||||
@@ -282,7 +282,7 @@ func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
|
|||||||
return salt, nil
|
return salt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) getEncryptionKey() ([]byte, error) {
|
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||||
salt, err := r.getOrCreateSalt()
|
salt, err := r.getOrCreateSalt()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -293,7 +293,7 @@ func (r *extensionRuntime) getEncryptionKey() ([]byte, error) {
|
|||||||
return hash[:], nil
|
return hash[:], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) ensureCredentialsLoaded() error {
|
func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
|
||||||
r.credentialsMu.RLock()
|
r.credentialsMu.RLock()
|
||||||
if r.credentialsLoaded {
|
if r.credentialsLoaded {
|
||||||
r.credentialsMu.RUnlock()
|
r.credentialsMu.RUnlock()
|
||||||
@@ -340,7 +340,17 @@ func (r *extensionRuntime) ensureCredentialsLoaded() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||||
|
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.credentialsMu.RLock()
|
||||||
|
defer r.credentialsMu.RUnlock()
|
||||||
|
return cloneInterfaceMap(r.credentialsCache), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||||
data, err := json.Marshal(creds)
|
data, err := json.Marshal(creds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -367,7 +377,7 @@ func (r *extensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -404,7 +414,7 @@ func (r *extensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
@@ -429,7 +439,7 @@ func (r *extensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(value)
|
return r.vm.ToValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
@@ -454,7 +464,7 @@ func (r *extensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
|
|||||||
return r.vm.ToValue(true)
|
return r.vm.ToValue(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue(false)
|
return r.vm.ToValue(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setStorageValue(t *testing.T, runtime *extensionRuntime, key string, value interface{}) {
|
func setStorageValue(t *testing.T, runtime *ExtensionRuntime, key string, value interface{}) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
result := runtime.storageSet(goja.FunctionCall{
|
result := runtime.storageSet(goja.FunctionCall{
|
||||||
Arguments: []goja.Value{
|
Arguments: []goja.Value{
|
||||||
@@ -39,7 +39,7 @@ func readStorageMap(t *testing.T, storagePath string) map[string]interface{} {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
||||||
ext := &loadedExtension{
|
ext := &LoadedExtension{
|
||||||
ID: "storage-test",
|
ID: "storage-test",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "storage-test",
|
Name: "storage-test",
|
||||||
@@ -47,7 +47,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
|||||||
DataDir: t.TempDir(),
|
DataDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := newExtensionRuntime(ext)
|
runtime := NewExtensionRuntime(ext)
|
||||||
runtime.storageFlushDelay = 25 * time.Millisecond
|
runtime.storageFlushDelay = 25 * time.Millisecond
|
||||||
runtime.RegisterAPIs(goja.New())
|
runtime.RegisterAPIs(goja.New())
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
|
func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
|
||||||
ext := &loadedExtension{
|
ext := &LoadedExtension{
|
||||||
ID: "unload-storage-test",
|
ID: "unload-storage-test",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "unload-storage-test",
|
Name: "unload-storage-test",
|
||||||
@@ -95,13 +95,13 @@ func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
|
|||||||
VM: goja.New(),
|
VM: goja.New(),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := newExtensionRuntime(ext)
|
runtime := NewExtensionRuntime(ext)
|
||||||
runtime.storageFlushDelay = time.Hour
|
runtime.storageFlushDelay = time.Hour
|
||||||
runtime.RegisterAPIs(ext.VM)
|
runtime.RegisterAPIs(ext.VM)
|
||||||
ext.runtime = runtime
|
ext.runtime = runtime
|
||||||
|
|
||||||
manager := &extensionManager{
|
manager := &ExtensionManager{
|
||||||
extensions: map[string]*loadedExtension{
|
extensions: map[string]*LoadedExtension{
|
||||||
ext.ID: ext,
|
ext.ID: ext,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,747 +0,0 @@
|
|||||||
package gobackend
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) {
|
|
||||||
vm := goja.New()
|
|
||||||
runtime := &extensionRuntime{
|
|
||||||
extensionID: "auth-ext",
|
|
||||||
manifest: &ExtensionManifest{
|
|
||||||
Name: "auth-ext",
|
|
||||||
Description: "Auth extension",
|
|
||||||
Version: "1.0.0",
|
|
||||||
Permissions: ExtensionPermissions{
|
|
||||||
Network: []string{"auth.example.com", "token.example.com", "api.example.com"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
settings: map[string]interface{}{},
|
|
||||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
switch req.URL.Host {
|
|
||||||
case "token.example.com":
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: io.NopCloser(strings.NewReader(`{"access_token":"access","refresh_token":"refresh","expires_in":3600}`)),
|
|
||||||
Request: req,
|
|
||||||
}, nil
|
|
||||||
case "api.example.com":
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Header: http.Header{"X-Test": []string{"yes"}},
|
|
||||||
Body: io.NopCloser(strings.NewReader(`{"ok":true,"items":[1,2]}`)),
|
|
||||||
Request: req,
|
|
||||||
}, nil
|
|
||||||
default:
|
|
||||||
return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
|
||||||
}
|
|
||||||
})},
|
|
||||||
vm: vm,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := validateExtensionAuthURL("https://user:pass@auth.example.com/login"); err == nil {
|
|
||||||
t.Fatal("expected embedded credential error")
|
|
||||||
}
|
|
||||||
if err := validateExtensionAuthURL("http://auth.example.com/login"); err == nil {
|
|
||||||
t.Fatal("expected non-https auth URL error")
|
|
||||||
}
|
|
||||||
if got := summarizeURLForLog("https://auth.example.com/login?token=secret"); got != "https://auth.example.com/login" {
|
|
||||||
t.Fatalf("summary = %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
openResult := runtime.authOpenUrl(goja.FunctionCall{Arguments: []goja.Value{
|
|
||||||
vm.ToValue("https://auth.example.com/login"),
|
|
||||||
vm.ToValue("app://callback"),
|
|
||||||
}}).Export().(map[string]interface{})
|
|
||||||
if openResult["success"] != true {
|
|
||||||
t.Fatalf("authOpenUrl = %#v", openResult)
|
|
||||||
}
|
|
||||||
if pending := GetPendingAuthRequest("auth-ext"); pending == nil || pending.AuthURL == "" {
|
|
||||||
t.Fatalf("pending auth = %#v", pending)
|
|
||||||
}
|
|
||||||
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]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" {
|
|
||||||
t.Fatalf("code = %q", code)
|
|
||||||
}
|
|
||||||
if !runtime.authIsAuthenticated(goja.FunctionCall{}).ToBoolean() {
|
|
||||||
t.Fatal("expected authenticated runtime")
|
|
||||||
}
|
|
||||||
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]interface{})
|
|
||||||
if pkce["method"] != "S256" || pkce["verifier"] == "" || pkce["challenge"] == "" {
|
|
||||||
t.Fatalf("pkce = %#v", pkce)
|
|
||||||
}
|
|
||||||
if current := runtime.authGetPKCE(goja.FunctionCall{}).Export().(map[string]interface{}); current["verifier"] == "" {
|
|
||||||
t.Fatalf("current pkce = %#v", current)
|
|
||||||
}
|
|
||||||
oauthConfig := map[string]interface{}{
|
|
||||||
"authUrl": "https://auth.example.com/oauth",
|
|
||||||
"clientId": "client",
|
|
||||||
"redirectUri": "app://callback",
|
|
||||||
"scope": "read",
|
|
||||||
"extraParams": map[string]interface{}{"prompt": "login"},
|
|
||||||
}
|
|
||||||
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]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]interface{})
|
|
||||||
if token["success"] != true || token["access_token"] != "access" {
|
|
||||||
t.Fatalf("token = %#v", token)
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime.registerTextEncoderDecoder(vm)
|
|
||||||
runtime.registerURLClass(vm)
|
|
||||||
runtime.registerJSONGlobal(vm)
|
|
||||||
vm.Set("fetch", func(call goja.FunctionCall) goja.Value {
|
|
||||||
return runtime.fetchPolyfill(call)
|
|
||||||
})
|
|
||||||
vm.Set("atob", func(call goja.FunctionCall) goja.Value {
|
|
||||||
return runtime.atobPolyfill(call)
|
|
||||||
})
|
|
||||||
vm.Set("btoa", func(call goja.FunctionCall) goja.Value {
|
|
||||||
return runtime.btoaPolyfill(call)
|
|
||||||
})
|
|
||||||
|
|
||||||
value, err := vm.RunString(`
|
|
||||||
var encoded = btoa("hello");
|
|
||||||
var decoded = atob(encoded);
|
|
||||||
var te = new TextEncoder();
|
|
||||||
var bytes = te.encode("hi");
|
|
||||||
var into = te.encodeInto("hi", []);
|
|
||||||
var td = new TextDecoder();
|
|
||||||
var text = td.decode(bytes);
|
|
||||||
var url = new URL("/path?a=1&a=2#frag", "https://api.example.com/base");
|
|
||||||
var params = new URLSearchParams("?x=1");
|
|
||||||
params.append("x", "2");
|
|
||||||
params.set("y", "3");
|
|
||||||
var response = fetch("https://api.example.com/data", {method: "POST", body: {q: "x"}, headers: {"X-Client": "test"}});
|
|
||||||
JSON.stringify({
|
|
||||||
encoded: encoded,
|
|
||||||
decoded: decoded,
|
|
||||||
text: text,
|
|
||||||
read: into.read,
|
|
||||||
host: url.hostname,
|
|
||||||
first: url.searchParams.get("a"),
|
|
||||||
all: url.searchParams.getAll("a").length,
|
|
||||||
params: params.toString(),
|
|
||||||
ok: response.ok,
|
|
||||||
status: response.status,
|
|
||||||
jsonOk: response.json().ok,
|
|
||||||
bufferLen: response.arrayBuffer().length
|
|
||||||
});
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("polyfill script: %v", err)
|
|
||||||
}
|
|
||||||
var result map[string]interface{}
|
|
||||||
if err := json.Unmarshal([]byte(value.String()), &result); err != nil {
|
|
||||||
t.Fatalf("decode polyfill result: %v", err)
|
|
||||||
}
|
|
||||||
if result["decoded"] != "hello" || result["host"] != "api.example.com" || result["ok"] != true {
|
|
||||||
t.Fatalf("polyfill result = %#v", result)
|
|
||||||
}
|
|
||||||
|
|
||||||
blocked := runtime.fetchPolyfill(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("https://blocked.example.com")}}).ToObject(vm)
|
|
||||||
if blocked.Get("ok").ToBoolean() {
|
|
||||||
t.Fatal("expected blocked fetch")
|
|
||||||
}
|
|
||||||
runtime.authClear(goja.FunctionCall{})
|
|
||||||
if runtime.authIsAuthenticated(goja.FunctionCall{}).ToBoolean() {
|
|
||||||
t.Fatal("expected auth cleared")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtensionStoreSettingsAndRuntimeStorage(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
store := &extensionStore{
|
|
||||||
registryURL: "https://registry.example.com/registry.json",
|
|
||||||
cacheDir: dir,
|
|
||||||
cacheTTL: time.Hour,
|
|
||||||
cache: &storeRegistry{
|
|
||||||
Version: 1,
|
|
||||||
UpdatedAt: "2026-05-04",
|
|
||||||
Extensions: []storeExtension{
|
|
||||||
{
|
|
||||||
ID: "coverage-ext",
|
|
||||||
Name: "coverage-ext",
|
|
||||||
DisplayNameAlt: "Coverage Extension",
|
|
||||||
Version: "2.0.0",
|
|
||||||
Description: "Metadata and lyrics provider",
|
|
||||||
DownloadURLAlt: "https://registry.example.com/coverage.spotiflac-ext",
|
|
||||||
IconURLAlt: "https://registry.example.com/icon.png",
|
|
||||||
Category: CategoryMetadata,
|
|
||||||
Tags: []string{"metadata", "lyrics"},
|
|
||||||
Downloads: 10,
|
|
||||||
UpdatedAt: "2026-05-04",
|
|
||||||
MinAppVersionAlt: "4.5.0",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "utility-ext",
|
|
||||||
Name: "utility-ext",
|
|
||||||
Version: "1.0.0",
|
|
||||||
Description: "Utility",
|
|
||||||
DownloadURL: "https://registry.example.com/utility.spotiflac-ext",
|
|
||||||
Category: CategoryUtility,
|
|
||||||
UpdatedAt: "2026-05-04",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cacheTime: time.Now(),
|
|
||||||
}
|
|
||||||
store.saveDiskCache()
|
|
||||||
loadedStore := &extensionStore{cacheDir: dir}
|
|
||||||
loadedStore.loadDiskCache()
|
|
||||||
if loadedStore.cache == nil || len(loadedStore.cache.Extensions) != 2 {
|
|
||||||
t.Fatalf("loaded cache = %#v", loadedStore.cache)
|
|
||||||
}
|
|
||||||
if got := store.getRegistryURL(); got != "https://registry.example.com/registry.json" {
|
|
||||||
t.Fatalf("registry URL = %q", got)
|
|
||||||
}
|
|
||||||
store.setRegistryURL("https://registry.example.com/new.json")
|
|
||||||
if store.cache != nil {
|
|
||||||
t.Fatal("expected cache reset after registry URL change")
|
|
||||||
}
|
|
||||||
store.cache = loadedStore.cache
|
|
||||||
store.cacheTime = time.Now()
|
|
||||||
|
|
||||||
manager := getExtensionManager()
|
|
||||||
manager.mu.Lock()
|
|
||||||
if manager.extensions == nil {
|
|
||||||
manager.extensions = map[string]*loadedExtension{}
|
|
||||||
}
|
|
||||||
manager.extensions["coverage-ext"] = &loadedExtension{
|
|
||||||
ID: "coverage-ext",
|
|
||||||
Manifest: &ExtensionManifest{
|
|
||||||
Name: "coverage-ext",
|
|
||||||
DisplayName: "Coverage Extension",
|
|
||||||
Version: "1.0.0",
|
|
||||||
Description: "Installed",
|
|
||||||
Types: []ExtensionType{ExtensionTypeMetadataProvider},
|
|
||||||
},
|
|
||||||
Enabled: true,
|
|
||||||
}
|
|
||||||
manager.mu.Unlock()
|
|
||||||
defer func() {
|
|
||||||
manager.mu.Lock()
|
|
||||||
delete(manager.extensions, "coverage-ext")
|
|
||||||
manager.mu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
extensions, err := store.getExtensionsWithStatus(false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("getExtensionsWithStatus: %v", err)
|
|
||||||
}
|
|
||||||
if len(extensions) != 2 || !extensions[0].IsInstalled || !extensions[0].HasUpdate {
|
|
||||||
t.Fatalf("extensions = %#v", extensions)
|
|
||||||
}
|
|
||||||
found, err := store.searchExtensions("lyrics", CategoryMetadata)
|
|
||||||
if err != nil || len(found) != 1 || found[0].ID != "coverage-ext" {
|
|
||||||
t.Fatalf("search = %#v/%v", found, err)
|
|
||||||
}
|
|
||||||
all, err := store.searchExtensions("", "")
|
|
||||||
if err != nil || len(all) != 2 {
|
|
||||||
t.Fatalf("all search = %#v/%v", all, err)
|
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
if _, err := resolveRegistryURL(""); err == nil {
|
|
||||||
t.Fatal("expected empty registry URL error")
|
|
||||||
}
|
|
||||||
if resolved, err := resolveRegistryURL("http://github.com/owner/repo"); err != nil || !strings.Contains(resolved, "raw.githubusercontent.com/owner/repo") {
|
|
||||||
t.Fatalf("resolved registry = %q/%v", resolved, err)
|
|
||||||
}
|
|
||||||
store.clearCache()
|
|
||||||
if store.cache != nil {
|
|
||||||
t.Fatal("expected cleared store cache")
|
|
||||||
}
|
|
||||||
|
|
||||||
settingsStore := &ExtensionSettingsStore{settings: map[string]map[string]interface{}{}}
|
|
||||||
if err := settingsStore.SetDataDir(filepath.Join(dir, "settings")); err != nil {
|
|
||||||
t.Fatalf("SetDataDir: %v", err)
|
|
||||||
}
|
|
||||||
if err := settingsStore.Set("ext", "quality", "lossless"); err != nil {
|
|
||||||
t.Fatalf("settings Set: %v", err)
|
|
||||||
}
|
|
||||||
if value, err := settingsStore.Get("ext", "quality"); err != nil || value != "lossless" {
|
|
||||||
t.Fatalf("settings Get = %#v/%v", value, err)
|
|
||||||
}
|
|
||||||
if _, err := settingsStore.Get("ext", "missing"); err == nil {
|
|
||||||
t.Fatal("expected missing setting error")
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
t.Fatalf("settings all = %#v", all)
|
|
||||||
}
|
|
||||||
if err := settingsStore.Remove("ext", "a"); err != nil {
|
|
||||||
t.Fatalf("settings Remove: %v", err)
|
|
||||||
}
|
|
||||||
if err := settingsStore.RemoveAll("ext"); err != nil {
|
|
||||||
t.Fatalf("settings RemoveAll: %v", err)
|
|
||||||
}
|
|
||||||
if jsonText, err := settingsStore.GetAllExtensionSettingsJSON(); err != nil || jsonText == "" {
|
|
||||||
t.Fatalf("settings JSON = %q/%v", jsonText, err)
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
storageFlushDelay: time.Hour,
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(runtime.dataDir, 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
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]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]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)
|
|
||||||
}
|
|
||||||
if ok := runtime.storageRemove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("key")}}); !ok.ToBoolean() {
|
|
||||||
t.Fatal("storageRemove false")
|
|
||||||
}
|
|
||||||
runtime.closeStorageFlusher()
|
|
||||||
if ok := runtime.storageSet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("after_close"), vm.ToValue("x")}}); ok.ToBoolean() {
|
|
||||||
t.Fatal("expected storageSet false after close")
|
|
||||||
}
|
|
||||||
|
|
||||||
credRuntime := &extensionRuntime{
|
|
||||||
extensionID: "cred-ext",
|
|
||||||
dataDir: filepath.Join(dir, "creds"),
|
|
||||||
vm: vm,
|
|
||||||
}
|
|
||||||
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]interface{}); result["success"] != true {
|
|
||||||
t.Fatalf("credentialsStore = %#v", result)
|
|
||||||
}
|
|
||||||
if got := credRuntime.credentialsGet(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).String(); got != "secret" {
|
|
||||||
t.Fatalf("credential = %q", got)
|
|
||||||
}
|
|
||||||
if !credRuntime.credentialsHas(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).ToBoolean() {
|
|
||||||
t.Fatal("expected credential")
|
|
||||||
}
|
|
||||||
if ok := credRuntime.credentialsRemove(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}); !ok.ToBoolean() {
|
|
||||||
t.Fatal("credentialsRemove false")
|
|
||||||
}
|
|
||||||
if credRuntime.credentialsHas(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("token")}}).ToBoolean() {
|
|
||||||
t.Fatal("expected credential removed")
|
|
||||||
}
|
|
||||||
key, err := credRuntime.getEncryptionKey()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("getEncryptionKey: %v", err)
|
|
||||||
}
|
|
||||||
encrypted, err := encryptAES([]byte("plain"), key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("encryptAES: %v", err)
|
|
||||||
}
|
|
||||||
decrypted, err := decryptAES(encrypted, key)
|
|
||||||
if err != nil || string(decrypted) != "plain" {
|
|
||||||
t.Fatalf("decryptAES = %q/%v", decrypted, err)
|
|
||||||
}
|
|
||||||
if _, err := decryptAES([]byte("short"), key); err == nil {
|
|
||||||
t.Fatal("expected short ciphertext error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtensionRuntimeHTTPMatchingAndMetadataHelpers(t *testing.T) {
|
|
||||||
vm := goja.New()
|
|
||||||
jar, _ := newSimpleCookieJar()
|
|
||||||
runtime := &extensionRuntime{
|
|
||||||
extensionID: "http-ext",
|
|
||||||
manifest: &ExtensionManifest{
|
|
||||||
Name: "http-ext",
|
|
||||||
Description: "HTTP extension",
|
|
||||||
Version: "1.0.0",
|
|
||||||
Permissions: ExtensionPermissions{
|
|
||||||
Network: []string{"api.example.com"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
vm: vm,
|
|
||||||
cookieJar: jar,
|
|
||||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
var body []byte
|
|
||||||
if req.Body != nil {
|
|
||||||
body, _ = io.ReadAll(req.Body)
|
|
||||||
}
|
|
||||||
header := make(http.Header)
|
|
||||||
header.Set("X-Method", req.Method)
|
|
||||||
if req.URL.Path == "/huge" {
|
|
||||||
return &http.Response{StatusCode: 200, Header: header, Body: io.NopCloser(io.LimitReader(strings.NewReader(strings.Repeat("x", maxExtensionHTTPResponseBytes+2)), maxExtensionHTTPResponseBytes+2)), Request: req}, nil
|
|
||||||
}
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: 201,
|
|
||||||
Header: header,
|
|
||||||
Body: io.NopCloser(strings.NewReader(req.Method + ":" + string(body))),
|
|
||||||
Request: req,
|
|
||||||
}, nil
|
|
||||||
})},
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := runtime.validateDomain("https://api.example.com/path"); err != nil {
|
|
||||||
t.Fatalf("validateDomain allowed: %v", err)
|
|
||||||
}
|
|
||||||
for _, rawURL := range []string{"notaurl", "http://api.example.com", "https://user:pass@api.example.com", "https://127.0.0.1/x", "https://blocked.example.com/x"} {
|
|
||||||
if err := runtime.validateDomain(rawURL); err == nil {
|
|
||||||
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]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]interface{}{"a": "b"})}}).Export().(map[string]interface{}); !strings.Contains(got["body"].(string), "POST") {
|
|
||||||
t.Fatalf("httpPost = %#v", got)
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
name string
|
|
||||||
call func(goja.FunctionCall) goja.Value
|
|
||||||
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]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]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]interface{}); !strings.Contains(got["error"].(string), "exceeds") {
|
|
||||||
t.Fatalf("huge response = %#v", got)
|
|
||||||
}
|
|
||||||
if !runtime.httpClearCookies(goja.FunctionCall{}).ToBoolean() {
|
|
||||||
t.Fatal("expected cookies cleared")
|
|
||||||
}
|
|
||||||
|
|
||||||
if runtime.matchingCompareStrings(goja.FunctionCall{}).ToFloat() != 0 {
|
|
||||||
t.Fatal("missing string compare args should be zero")
|
|
||||||
}
|
|
||||||
if runtime.matchingCompareStrings(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song"), vm.ToValue("song")}}).ToFloat() != 1 {
|
|
||||||
t.Fatal("expected exact string similarity")
|
|
||||||
}
|
|
||||||
if runtime.matchingCompareDuration(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(180000), vm.ToValue(182000)}}).ToBoolean() != true {
|
|
||||||
t.Fatal("expected duration match")
|
|
||||||
}
|
|
||||||
if runtime.matchingNormalizeString(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song (Remastered) feat. Guest!")}}).String() != "song" {
|
|
||||||
t.Fatalf("normalized = %q", runtime.matchingNormalizeString(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("Song (Remastered) feat. Guest!")}}).String())
|
|
||||||
}
|
|
||||||
|
|
||||||
if formatMusicBrainzGenre([]musicBrainzTag{{Count: 1, Name: "rock"}, {Count: 5, Name: "electronic"}, {Count: 10, Name: "rock"}}) != "Electronic" {
|
|
||||||
t.Fatal("unexpected genre selection")
|
|
||||||
}
|
|
||||||
credits := []musicBrainzArtistCredit{{Name: "A", JoinPhrase: " & "}, {Name: "B"}}
|
|
||||||
if formatMusicBrainzArtistCredit(credits) != "A & B" {
|
|
||||||
t.Fatal("artist credit format mismatch")
|
|
||||||
}
|
|
||||||
releases := []musicBrainzRelease{
|
|
||||||
{Title: "Other", ArtistCredit: []musicBrainzArtistCredit{{Name: "Fallback"}}},
|
|
||||||
{Title: "Album", ArtistCredit: credits},
|
|
||||||
}
|
|
||||||
if selectMusicBrainzAlbumArtist(releases, "Album") != "A & B" || selectMusicBrainzAlbumArtist(releases, "") != "Fallback" {
|
|
||||||
t.Fatal("album artist selection mismatch")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtensionRuntimeFileAPIs(t *testing.T) {
|
|
||||||
vm := goja.New()
|
|
||||||
dir := t.TempDir()
|
|
||||||
SetAllowedDownloadDirs(nil)
|
|
||||||
defer SetAllowedDownloadDirs(nil)
|
|
||||||
|
|
||||||
fileBody := "chunk"
|
|
||||||
runtime := &extensionRuntime{
|
|
||||||
extensionID: "file-ext",
|
|
||||||
manifest: &ExtensionManifest{
|
|
||||||
Name: "file-ext",
|
|
||||||
Description: "File extension",
|
|
||||||
Version: "1.0.0",
|
|
||||||
Permissions: ExtensionPermissions{
|
|
||||||
File: true,
|
|
||||||
Network: []string{"files.example.com"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
dataDir: dir,
|
|
||||||
vm: vm,
|
|
||||||
httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
||||||
if req.Header.Get("Range") == "" {
|
|
||||||
body := "downloaded"
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: io.NopCloser(strings.NewReader(body)),
|
|
||||||
ContentLength: int64(len(body)),
|
|
||||||
Request: req,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
rangeHeader := req.Header.Get("Range")
|
|
||||||
start, end := 0, len(fileBody)-1
|
|
||||||
if _, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end); err != nil {
|
|
||||||
start, end = 0, 1
|
|
||||||
}
|
|
||||||
if start < 0 {
|
|
||||||
start = 0
|
|
||||||
}
|
|
||||||
if end >= len(fileBody) {
|
|
||||||
end = len(fileBody) - 1
|
|
||||||
}
|
|
||||||
if start > len(fileBody) {
|
|
||||||
start = len(fileBody)
|
|
||||||
}
|
|
||||||
body := fileBody[start : end+1]
|
|
||||||
header := http.Header{"Content-Range": []string{fmt.Sprintf("bytes %d-%d/%d", start, end, len(fileBody))}}
|
|
||||||
return &http.Response{StatusCode: 206, Header: header, Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil
|
|
||||||
})},
|
|
||||||
}
|
|
||||||
runtime.downloadClient = runtime.httpClient
|
|
||||||
|
|
||||||
if _, err := (&extensionRuntime{manifest: &ExtensionManifest{}}).validatePath("x"); err == nil {
|
|
||||||
t.Fatal("expected file permission error")
|
|
||||||
}
|
|
||||||
if _, err := runtime.validatePath("../escape.txt"); err == nil {
|
|
||||||
t.Fatal("expected sandbox escape error")
|
|
||||||
}
|
|
||||||
AddAllowedDownloadDir(dir)
|
|
||||||
absolutePath := filepath.Join(dir, "allowed.txt")
|
|
||||||
if got, err := runtime.validatePath(absolutePath); err != nil || got != absolutePath {
|
|
||||||
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]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]interface{})
|
|
||||||
if read["data"] != "hello" {
|
|
||||||
t.Fatalf("fileRead = %#v", read)
|
|
||||||
}
|
|
||||||
|
|
||||||
writeBytes := runtime.fileWriteBytes(goja.FunctionCall{Arguments: []goja.Value{
|
|
||||||
vm.ToValue("nested/bytes.bin"),
|
|
||||||
vm.ToValue("4869"),
|
|
||||||
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([]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]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]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]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]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]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]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]interface{})
|
|
||||||
if deleteResult["success"] != true {
|
|
||||||
t.Fatalf("fileDelete = %#v", deleteResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
download := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
|
||||||
vm.ToValue("https://files.example.com/file"),
|
|
||||||
vm.ToValue("downloads/file.bin"),
|
|
||||||
}}).Export().(map[string]interface{})
|
|
||||||
if download["success"] != true {
|
|
||||||
t.Fatalf("fileDownload = %#v", download)
|
|
||||||
}
|
|
||||||
if data, err := os.ReadFile(filepath.Join(dir, "downloads/file.bin")); err != nil || string(data) != "downloaded" {
|
|
||||||
t.Fatalf("downloaded data = %q/%v", data, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
chunked := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{
|
|
||||||
vm.ToValue("https://files.example.com/chunk"),
|
|
||||||
vm.ToValue("downloads/chunk.bin"),
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
if data, err := os.ReadFile(filepath.Join(dir, "downloads/chunk.bin")); err != nil || string(data) != fileBody {
|
|
||||||
t.Fatalf("chunked data = %q/%v", data, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if missing := runtime.fileDownload(goja.FunctionCall{}).Export().(map[string]interface{}); missing["success"] != false {
|
|
||||||
t.Fatalf("expected missing download args error, got %#v", missing)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtensionRuntimeUtilityAPIs(t *testing.T) {
|
|
||||||
vm := goja.New()
|
|
||||||
runtime := &extensionRuntime{extensionID: "utils-ext", vm: vm}
|
|
||||||
|
|
||||||
if runtime.sha256Hash(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("abc")}}).String() == "" {
|
|
||||||
t.Fatal("expected sha256")
|
|
||||||
}
|
|
||||||
if runtime.hmacSHA256(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("msg"), vm.ToValue("key")}}).String() == "" {
|
|
||||||
t.Fatal("expected hmac sha256")
|
|
||||||
}
|
|
||||||
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([]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]interface{})
|
|
||||||
if parsed["ok"] != true {
|
|
||||||
t.Fatalf("parseJSON = %#v", parsed)
|
|
||||||
}
|
|
||||||
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]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]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]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]interface{})
|
|
||||||
if key["success"] != true || key["key"] == "" || key["hex"] == "" {
|
|
||||||
t.Fatalf("cryptoGenerateKey = %#v", key)
|
|
||||||
}
|
|
||||||
if runtime.randomUserAgent(goja.FunctionCall{}).String() == "" || runtime.appUserAgent(goja.FunctionCall{}).String() == "" {
|
|
||||||
t.Fatal("expected user agents")
|
|
||||||
}
|
|
||||||
SetAppVersion("9.9.9")
|
|
||||||
if runtime.appVersion(goja.FunctionCall{}).String() != "9.9.9" {
|
|
||||||
t.Fatal("appVersion mismatch")
|
|
||||||
}
|
|
||||||
if !runtime.sleep(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(float64(0))}}).ToBoolean() {
|
|
||||||
t.Fatal("zero sleep should succeed")
|
|
||||||
}
|
|
||||||
|
|
||||||
itemID := "utils-item"
|
|
||||||
runtime.setActiveDownloadItemID(itemID)
|
|
||||||
initDownloadCancel(itemID)
|
|
||||||
if runtime.isDownloadCancelled(goja.FunctionCall{}).ToBoolean() {
|
|
||||||
t.Fatal("item should not be cancelled yet")
|
|
||||||
}
|
|
||||||
runtime.setDownloadStatus(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue(itemProgressStatusDownloading)}})
|
|
||||||
cancelDownload(itemID)
|
|
||||||
if !runtime.isDownloadCancelled(goja.FunctionCall{}).ToBoolean() {
|
|
||||||
t.Fatal("item should be cancelled")
|
|
||||||
}
|
|
||||||
clearDownloadCancel(itemID)
|
|
||||||
runtime.clearActiveDownloadItemID()
|
|
||||||
|
|
||||||
requestID := "utils-request"
|
|
||||||
runtime.setActiveRequestID(requestID)
|
|
||||||
initExtensionRequestCancel(requestID)
|
|
||||||
if runtime.isRequestCancelled(goja.FunctionCall{}).ToBoolean() {
|
|
||||||
t.Fatal("request should not be cancelled yet")
|
|
||||||
}
|
|
||||||
cancelExtensionRequest(requestID)
|
|
||||||
if !runtime.isRequestCancelled(goja.FunctionCall{}).ToBoolean() {
|
|
||||||
t.Fatal("request should be cancelled")
|
|
||||||
}
|
|
||||||
clearExtensionRequestCancel(requestID)
|
|
||||||
runtime.clearActiveRequestID()
|
|
||||||
|
|
||||||
if msg := runtime.formatLogArgs([]goja.Value{vm.ToValue("a"), vm.ToValue(1)}); msg != "a 1" {
|
|
||||||
t.Fatalf("formatLogArgs = %q", msg)
|
|
||||||
}
|
|
||||||
runtime.logDebug(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("debug")}})
|
|
||||||
runtime.logInfo(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("info")}})
|
|
||||||
runtime.logWarn(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("warn")}})
|
|
||||||
runtime.logError(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("error")}})
|
|
||||||
if clean := runtime.sanitizeFilenameWrapper(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("A/B?")}}).String(); strings.ContainsAny(clean, "/?") {
|
|
||||||
t.Fatalf("sanitize wrapper = %q", clean)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (r *extensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ func (r *extensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ func (r *extensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(string(decoded))
|
return r.vm.ToValue(string(decoded))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ func (r *extensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ func (r *extensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ func (r *extensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@ func (r *extensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue([]byte{})
|
return r.vm.ToValue([]byte{})
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ func (r *extensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(jsArray)
|
return r.vm.ToValue(jsArray)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
@@ -145,7 +145,7 @@ func (r *extensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(result)
|
return r.vm.ToValue(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -160,7 +160,7 @@ func (r *extensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
|||||||
return r.vm.ToValue(string(data))
|
return r.vm.ToValue(string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -187,7 +187,7 @@ func (r *extensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
@@ -222,7 +222,7 @@ func (r *extensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
||||||
length := 32
|
length := 32
|
||||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||||
if l, ok := call.Arguments[0].Export().(float64); ok {
|
if l, ok := call.Arguments[0].Export().(float64); ok {
|
||||||
@@ -245,125 +245,35 @@ func (r *extensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
||||||
return r.vm.ToValue(getRandomUserAgent())
|
return r.vm.ToValue(getRandomUserAgent())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) appVersion(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||||
return r.vm.ToValue(GetAppVersion())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *extensionRuntime) appUserAgent(call goja.FunctionCall) goja.Value {
|
|
||||||
return r.vm.ToValue(appUserAgent())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *extensionRuntime) sleep(call goja.FunctionCall) goja.Value {
|
|
||||||
if len(call.Arguments) < 1 {
|
|
||||||
return r.vm.ToValue(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
sleepMs := 0
|
|
||||||
switch value := call.Arguments[0].Export().(type) {
|
|
||||||
case int64:
|
|
||||||
sleepMs = int(value)
|
|
||||||
case int32:
|
|
||||||
sleepMs = int(value)
|
|
||||||
case int:
|
|
||||||
sleepMs = value
|
|
||||||
case float64:
|
|
||||||
sleepMs = int(value)
|
|
||||||
default:
|
|
||||||
sleepMs = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if sleepMs <= 0 {
|
|
||||||
return r.vm.ToValue(true)
|
|
||||||
}
|
|
||||||
if sleepMs > 5*60*1000 {
|
|
||||||
sleepMs = 5 * 60 * 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
itemID := r.getActiveDownloadItemID()
|
|
||||||
deadline := time.Now().Add(time.Duration(sleepMs) * time.Millisecond)
|
|
||||||
|
|
||||||
for {
|
|
||||||
if itemID != "" && isDownloadCancelled(itemID) {
|
|
||||||
return r.vm.ToValue(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
remaining := time.Until(deadline)
|
|
||||||
if remaining <= 0 {
|
|
||||||
return r.vm.ToValue(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
step := 100 * time.Millisecond
|
|
||||||
if remaining < step {
|
|
||||||
step = remaining
|
|
||||||
}
|
|
||||||
time.Sleep(step)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *extensionRuntime) isDownloadCancelled(call goja.FunctionCall) goja.Value {
|
|
||||||
itemID := r.getActiveDownloadItemID()
|
|
||||||
if itemID == "" {
|
|
||||||
return r.vm.ToValue(false)
|
|
||||||
}
|
|
||||||
return r.vm.ToValue(isDownloadCancelled(itemID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *extensionRuntime) isRequestCancelled(call goja.FunctionCall) goja.Value {
|
|
||||||
requestID := r.getActiveRequestID()
|
|
||||||
if requestID == "" {
|
|
||||||
return r.vm.ToValue(false)
|
|
||||||
}
|
|
||||||
return r.vm.ToValue(isExtensionRequestCancelled(requestID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *extensionRuntime) setDownloadStatus(call goja.FunctionCall) goja.Value {
|
|
||||||
itemID := r.getActiveDownloadItemID()
|
|
||||||
if itemID == "" || len(call.Arguments) < 1 {
|
|
||||||
return goja.Undefined()
|
|
||||||
}
|
|
||||||
|
|
||||||
status := strings.ToLower(strings.TrimSpace(call.Arguments[0].String()))
|
|
||||||
switch status {
|
|
||||||
case itemProgressStatusPreparing:
|
|
||||||
SetItemPreparing(itemID)
|
|
||||||
case itemProgressStatusDownloading:
|
|
||||||
SetItemDownloading(itemID)
|
|
||||||
case itemProgressStatusFinalizing:
|
|
||||||
SetItemFinalizing(itemID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return goja.Undefined()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
|
||||||
msg := r.formatLogArgs(call.Arguments)
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
|
||||||
msg := r.formatLogArgs(call.Arguments)
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
|
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
|
||||||
msg := r.formatLogArgs(call.Arguments)
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
|
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) logError(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value {
|
||||||
msg := r.formatLogArgs(call.Arguments)
|
msg := r.formatLogArgs(call.Arguments)
|
||||||
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
|
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) formatLogArgs(args []goja.Value) string {
|
func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||||
parts := make([]string, len(args))
|
parts := make([]string, len(args))
|
||||||
for i, arg := range args {
|
for i, arg := range args {
|
||||||
parts[i] = fmt.Sprintf("%v", arg.Export())
|
parts[i] = fmt.Sprintf("%v", arg.Export())
|
||||||
@@ -371,7 +281,7 @@ func (r *extensionRuntime) formatLogArgs(args []goja.Value) string {
|
|||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 1 {
|
if len(call.Arguments) < 1 {
|
||||||
return r.vm.ToValue("")
|
return r.vm.ToValue("")
|
||||||
}
|
}
|
||||||
@@ -379,7 +289,7 @@ func (r *extensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.
|
|||||||
return r.vm.ToValue(sanitizeFilename(input))
|
return r.vm.ToValue(sanitizeFilename(input))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||||
gobackendObj := vm.Get("gobackend")
|
gobackendObj := vm.Get("gobackend")
|
||||||
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
||||||
gobackendObj = vm.NewObject()
|
gobackendObj = vm.NewObject()
|
||||||
@@ -414,83 +324,6 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
|||||||
"bitDepth": quality.BitDepth,
|
"bitDepth": quality.BitDepth,
|
||||||
"sampleRate": quality.SampleRate,
|
"sampleRate": quality.SampleRate,
|
||||||
"totalSamples": quality.TotalSamples,
|
"totalSamples": quality.TotalSamples,
|
||||||
"duration": quality.Duration,
|
|
||||||
"codec": quality.Codec,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
obj.Set("getLyricsLRC", func(call goja.FunctionCall) goja.Value {
|
|
||||||
if len(call.Arguments) < 3 {
|
|
||||||
return vm.ToValue(map[string]interface{}{
|
|
||||||
"error": "spotifyID, trackName, and artistName are required",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
spotifyID := strings.TrimSpace(call.Arguments[0].String())
|
|
||||||
trackName := strings.TrimSpace(call.Arguments[1].String())
|
|
||||||
artistName := strings.TrimSpace(call.Arguments[2].String())
|
|
||||||
filePath := ""
|
|
||||||
if len(call.Arguments) > 3 && !goja.IsUndefined(call.Arguments[3]) && !goja.IsNull(call.Arguments[3]) {
|
|
||||||
filePath = strings.TrimSpace(call.Arguments[3].String())
|
|
||||||
}
|
|
||||||
var durationMs int64
|
|
||||||
if len(call.Arguments) > 4 && !goja.IsUndefined(call.Arguments[4]) && !goja.IsNull(call.Arguments[4]) {
|
|
||||||
durationMs = call.Arguments[4].ToInteger()
|
|
||||||
}
|
|
||||||
|
|
||||||
lyrics, err := GetLyricsLRC(spotifyID, trackName, artistName, filePath, durationMs)
|
|
||||||
if err != nil {
|
|
||||||
return vm.ToValue(map[string]interface{}{
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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]interface{}{
|
|
||||||
"error": "outputDir and isrc are required",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
outputDir := strings.TrimSpace(call.Arguments[0].String())
|
|
||||||
isrc := strings.TrimSpace(call.Arguments[1].String())
|
|
||||||
if outputDir == "" || isrc == "" {
|
|
||||||
return vm.ToValue(map[string]interface{}{
|
|
||||||
"error": "outputDir and isrc are required",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
filePath, exists := checkISRCExistsInternal(outputDir, isrc)
|
|
||||||
return vm.ToValue(map[string]interface{}{
|
|
||||||
"exists": exists,
|
|
||||||
"filePath": filePath,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
obj.Set("addToISRCIndex", func(call goja.FunctionCall) goja.Value {
|
|
||||||
if len(call.Arguments) < 3 {
|
|
||||||
return vm.ToValue(map[string]interface{}{
|
|
||||||
"error": "outputDir, isrc, and filePath are required",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
outputDir := strings.TrimSpace(call.Arguments[0].String())
|
|
||||||
isrc := strings.TrimSpace(call.Arguments[1].String())
|
|
||||||
filePath := strings.TrimSpace(call.Arguments[2].String())
|
|
||||||
if outputDir == "" || isrc == "" || filePath == "" {
|
|
||||||
return vm.ToValue(map[string]interface{}{
|
|
||||||
"error": "outputDir, isrc, and filePath are required",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
AddToISRCIndex(outputDir, isrc, filePath)
|
|
||||||
return vm.ToValue(map[string]interface{}{
|
|
||||||
"success": true,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,664 +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 (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Value {
|
|
||||||
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
|
||||||
if config.Namespace == "" || config.BaseURL == "" {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": "signedSession is not configured"})
|
|
||||||
}
|
|
||||||
record, err := r.loadSignedSession(config)
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": err.Error()})
|
|
||||||
}
|
|
||||||
authenticated := record.SessionID != "" && record.SessionSecret != ""
|
|
||||||
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok && time.Now().After(expiresAt) {
|
|
||||||
authenticated = false
|
|
||||||
}
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"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]interface{}{"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]interface{}{"success": false, "error": err.Error()})
|
|
||||||
}
|
|
||||||
ClearPendingAuthRequest(r.extensionID)
|
|
||||||
return r.vm.ToValue(map[string]interface{}{"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]interface{}{"success": false, "error": "no pending grant"})
|
|
||||||
}
|
|
||||||
if err := r.exchangeSignedSessionGrant(grant); err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
|
||||||
}
|
|
||||||
ClearPendingAuthRequest(r.extensionID)
|
|
||||||
return r.vm.ToValue(map[string]interface{}{"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]interface{}{
|
|
||||||
"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]interface{}{"ok": false, "error": "method and path are required"})
|
|
||||||
}
|
|
||||||
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
|
||||||
if config.Namespace == "" || config.BaseURL == "" {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{"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]interface{}, []interface{}:
|
|
||||||
encoded, err := json.Marshal(v)
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
|
|
||||||
}
|
|
||||||
body = encoded
|
|
||||||
default:
|
|
||||||
body = []byte(call.Arguments[2].String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
extraHeaders := map[string]string{}
|
|
||||||
if len(call.Arguments) > 3 && !goja.IsUndefined(call.Arguments[3]) && !goja.IsNull(call.Arguments[3]) {
|
|
||||||
if h, ok := call.Arguments[3].Export().(map[string]interface{}); ok {
|
|
||||||
for k, v := range h {
|
|
||||||
extraHeaders[k] = fmt.Sprintf("%v", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
record, err := r.ensureSignedSession(config)
|
|
||||||
if err != nil {
|
|
||||||
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
|
|
||||||
return r.signedSessionVerificationRequiredValue(authURL)
|
|
||||||
}
|
|
||||||
return r.vm.ToValue(map[string]interface{}{"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]interface{}{"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]interface{}{
|
|
||||||
"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 {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"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, reason 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,
|
|
||||||
}
|
|
||||||
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]interface{}, 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]interface{})
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
@@ -26,6 +26,7 @@ type storeExtension struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name,omitempty"`
|
DisplayName string `json:"display_name,omitempty"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
|
Author string `json:"author"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
DownloadURL string `json:"download_url,omitempty"`
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
IconURL string `json:"icon_url,omitempty"`
|
IconURL string `json:"icon_url,omitempty"`
|
||||||
@@ -82,6 +83,7 @@ type storeExtensionResponse struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
|
Author string `json:"author"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
DownloadURL string `json:"download_url"`
|
DownloadURL string `json:"download_url"`
|
||||||
IconURL string `json:"icon_url,omitempty"`
|
IconURL string `json:"icon_url,omitempty"`
|
||||||
@@ -101,6 +103,7 @@ func (e *storeExtension) toResponse() storeExtensionResponse {
|
|||||||
Name: e.Name,
|
Name: e.Name,
|
||||||
DisplayName: e.getDisplayName(),
|
DisplayName: e.getDisplayName(),
|
||||||
Version: e.Version,
|
Version: e.Version,
|
||||||
|
Author: e.Author,
|
||||||
Description: e.Description,
|
Description: e.Description,
|
||||||
DownloadURL: e.getDownloadURL(),
|
DownloadURL: e.getDownloadURL(),
|
||||||
IconURL: e.getIconURL(),
|
IconURL: e.getIconURL(),
|
||||||
@@ -250,17 +253,7 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
|
|||||||
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
||||||
|
|
||||||
client := NewHTTPClientWithTimeout(30 * time.Second)
|
client := NewHTTPClientWithTimeout(30 * time.Second)
|
||||||
req, err := http.NewRequest(http.MethodGet, s.registryURL, nil)
|
resp, err := client.Get(s.registryURL)
|
||||||
if err != nil {
|
|
||||||
if s.cache != nil {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
req.Header.Set("Cache-Control", "no-cache")
|
|
||||||
req.Header.Set("Pragma", "no-cache")
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if s.cache != nil {
|
if s.cache != nil {
|
||||||
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
||||||
@@ -302,7 +295,7 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
manager := getExtensionManager()
|
manager := GetExtensionManager()
|
||||||
installed := make(map[string]string) // id -> version
|
installed := make(map[string]string) // id -> version
|
||||||
|
|
||||||
if manager != nil {
|
if manager != nil {
|
||||||
@@ -330,26 +323,22 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *extensionStore) findExtension(extensionID string) (*storeExtension, error) {
|
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
|
||||||
registry, err := s.fetchRegistry(false)
|
registry, err := s.fetchRegistry(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ext *storeExtension
|
||||||
for _, e := range registry.Extensions {
|
for _, e := range registry.Extensions {
|
||||||
if e.ID == extensionID {
|
if e.ID == extensionID {
|
||||||
ext := e
|
ext = &e
|
||||||
return &ext, nil
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("extension %s not found in store", extensionID)
|
if ext == nil {
|
||||||
}
|
return fmt.Errorf("extension %s not found in store", extensionID)
|
||||||
|
|
||||||
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
|
|
||||||
ext, err := s.findExtension(extensionID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
|
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
|
||||||
@@ -359,13 +348,7 @@ func (s *extensionStore) downloadExtension(extensionID string, destPath string)
|
|||||||
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
||||||
|
|
||||||
client := NewHTTPClientWithTimeout(5 * time.Minute)
|
client := NewHTTPClientWithTimeout(5 * time.Minute)
|
||||||
req, err := http.NewRequest(http.MethodGet, ext.getDownloadURL(), nil)
|
resp, err := client.Get(ext.getDownloadURL())
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to build download request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Cache-Control", "no-cache")
|
|
||||||
req.Header.Set("Pragma", "no-cache")
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to download: %w", err)
|
return fmt.Errorf("failed to download: %w", err)
|
||||||
}
|
}
|
||||||
@@ -498,7 +481,8 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor
|
|||||||
if query != "" {
|
if query != "" {
|
||||||
if !containsIgnoreCase(ext.Name, queryLower) &&
|
if !containsIgnoreCase(ext.Name, queryLower) &&
|
||||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||||
!containsIgnoreCase(ext.Description, queryLower) {
|
!containsIgnoreCase(ext.Description, queryLower) &&
|
||||||
|
!containsIgnoreCase(ext.Author, queryLower) {
|
||||||
found := false
|
found := false
|
||||||
for _, tag := range ext.Tags {
|
for _, tag := range ext.Tags {
|
||||||
if containsIgnoreCase(tag, queryLower) {
|
if containsIgnoreCase(tag, queryLower) {
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
@@ -16,6 +12,7 @@ func TestParseManifest_Valid(t *testing.T) {
|
|||||||
"name": "test-provider",
|
"name": "test-provider",
|
||||||
"displayName": "Test Provider",
|
"displayName": "Test Provider",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"author": "Test Author",
|
||||||
"description": "A test extension",
|
"description": "A test extension",
|
||||||
"type": ["metadata_provider"],
|
"type": ["metadata_provider"],
|
||||||
"permissions": {
|
"permissions": {
|
||||||
@@ -46,26 +43,10 @@ func TestParseManifest_Valid(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtensionManifestStopsProviderFallback(t *testing.T) {
|
|
||||||
modernManifest := &ExtensionManifest{StopProviderFallback: true}
|
|
||||||
if !modernManifest.StopsProviderFallback() {
|
|
||||||
t.Fatal("expected stopProviderFallback to stop provider fallback")
|
|
||||||
}
|
|
||||||
|
|
||||||
legacyManifest := &ExtensionManifest{SkipBuiltInFallback: true}
|
|
||||||
if !legacyManifest.StopsProviderFallback() {
|
|
||||||
t.Fatal("expected legacy skipBuiltInFallback to stop provider fallback")
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultManifest := &ExtensionManifest{}
|
|
||||||
if defaultManifest.StopsProviderFallback() {
|
|
||||||
t.Fatal("expected default manifest to allow provider fallback")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseManifest_MissingName(t *testing.T) {
|
func TestParseManifest_MissingName(t *testing.T) {
|
||||||
invalidManifest := `{
|
invalidManifest := `{
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"author": "Test Author",
|
||||||
"description": "A test extension",
|
"description": "A test extension",
|
||||||
"type": ["metadata_provider"]
|
"type": ["metadata_provider"]
|
||||||
}`
|
}`
|
||||||
@@ -80,6 +61,7 @@ func TestParseManifest_MissingType(t *testing.T) {
|
|||||||
invalidManifest := `{
|
invalidManifest := `{
|
||||||
"name": "test-provider",
|
"name": "test-provider",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"author": "Test Author",
|
||||||
"description": "A test extension"
|
"description": "A test extension"
|
||||||
}`
|
}`
|
||||||
|
|
||||||
@@ -116,7 +98,8 @@ func TestIsDomainAllowed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||||
ext := &loadedExtension{
|
// Create a mock extension with limited network permissions
|
||||||
|
ext := &LoadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext",
|
Name: "test-ext",
|
||||||
@@ -127,7 +110,7 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
|||||||
DataDir: t.TempDir(),
|
DataDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := newExtensionRuntime(ext)
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
|
||||||
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
||||||
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
||||||
@@ -144,21 +127,12 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
|||||||
if err := runtime.validateDomain("https://notallowed.com/path"); err == nil {
|
if err := runtime.validateDomain("https://notallowed.com/path"); err == nil {
|
||||||
t.Error("Expected notallowed.com to be denied")
|
t.Error("Expected notallowed.com to be denied")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := runtime.validateDomain("http://api.allowed.com/path"); err == nil {
|
|
||||||
t.Error("Expected http URL to be denied without allowHttp")
|
|
||||||
}
|
|
||||||
|
|
||||||
ext.Manifest.Permissions.AllowHTTP = true
|
|
||||||
if err := runtime.validateDomain("http://api.allowed.com/path"); err != nil {
|
|
||||||
t.Errorf("Expected http URL to be allowed with allowHttp, got error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
ext := &loadedExtension{
|
ext := &LoadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext",
|
Name: "test-ext",
|
||||||
@@ -169,7 +143,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
DataDir: tempDir,
|
DataDir: tempDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := newExtensionRuntime(ext)
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
|
||||||
validPath, err := runtime.validatePath("test.txt")
|
validPath, err := runtime.validatePath("test.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -203,7 +177,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
t.Error("Expected absolute path to be blocked")
|
t.Error("Expected absolute path to be blocked")
|
||||||
}
|
}
|
||||||
|
|
||||||
extNoFile := &loadedExtension{
|
extNoFile := &LoadedExtension{
|
||||||
ID: "test-ext-no-file",
|
ID: "test-ext-no-file",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext-no-file",
|
Name: "test-ext-no-file",
|
||||||
@@ -213,7 +187,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
},
|
},
|
||||||
DataDir: tempDir,
|
DataDir: tempDir,
|
||||||
}
|
}
|
||||||
runtimeNoFile := newExtensionRuntime(extNoFile)
|
runtimeNoFile := NewExtensionRuntime(extNoFile)
|
||||||
_, err = runtimeNoFile.validatePath("test.txt")
|
_, err = runtimeNoFile.validatePath("test.txt")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Expected file access to be denied without file permission")
|
t.Error("Expected file access to be denied without file permission")
|
||||||
@@ -221,7 +195,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||||
ext := &loadedExtension{
|
ext := &LoadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext",
|
Name: "test-ext",
|
||||||
@@ -229,7 +203,7 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
|||||||
DataDir: t.TempDir(),
|
DataDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := newExtensionRuntime(ext)
|
runtime := NewExtensionRuntime(ext)
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
runtime.RegisterAPIs(vm)
|
runtime.RegisterAPIs(vm)
|
||||||
|
|
||||||
@@ -261,177 +235,15 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("stringifyJSON failed: %v", err)
|
t.Fatalf("stringifyJSON failed: %v", err)
|
||||||
}
|
}
|
||||||
|
// JSON output may vary in order, just check it's valid
|
||||||
if result.String() == "" {
|
if result.String() == "" {
|
||||||
t.Error("Expected non-empty JSON string")
|
t.Error("Expected non-empty JSON string")
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err = vm.RunString(`utils.sleep(1)`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("sleep failed: %v", err)
|
|
||||||
}
|
|
||||||
if !result.ToBoolean() {
|
|
||||||
t.Error("Expected sleep to complete successfully")
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime.setActiveDownloadItemID("test-item")
|
|
||||||
cancelDownload("test-item")
|
|
||||||
t.Cleanup(func() {
|
|
||||||
clearDownloadCancel("test-item")
|
|
||||||
runtime.clearActiveDownloadItemID()
|
|
||||||
})
|
|
||||||
|
|
||||||
result, err = vm.RunString(`utils.isDownloadCancelled()`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("isDownloadCancelled failed: %v", err)
|
|
||||||
}
|
|
||||||
if !result.ToBoolean() {
|
|
||||||
t.Error("Expected active download cancellation to be visible to JS")
|
|
||||||
}
|
|
||||||
|
|
||||||
SetAppVersion("4.2.2")
|
|
||||||
t.Cleanup(func() {
|
|
||||||
SetAppVersion("")
|
|
||||||
})
|
|
||||||
|
|
||||||
result, err = vm.RunString(`utils.appVersion()`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("appVersion failed: %v", err)
|
|
||||||
}
|
|
||||||
if got := result.String(); got != "4.2.2" {
|
|
||||||
t.Fatalf("Expected appVersion 4.2.2, got %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err = vm.RunString(`utils.appUserAgent()`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("appUserAgent failed: %v", err)
|
|
||||||
}
|
|
||||||
if got := result.String(); got != "SpotiFLAC-Mobile/4.2.2" {
|
|
||||||
t.Fatalf("Expected appUserAgent SpotiFLAC-Mobile/4.2.2, got %q", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err = vm.RunString(`utils.sleep(50)`)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("cancel-aware sleep failed: %v", err)
|
|
||||||
}
|
|
||||||
if result.ToBoolean() {
|
|
||||||
t.Error("Expected sleep to abort when download is cancelled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtensionRuntime_BindDownloadCancelContext(t *testing.T) {
|
|
||||||
ext := &loadedExtension{
|
|
||||||
ID: "test-ext",
|
|
||||||
Manifest: &ExtensionManifest{
|
|
||||||
Name: "test-ext",
|
|
||||||
},
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime := newExtensionRuntime(ext)
|
|
||||||
runtime.setActiveDownloadItemID("test-item")
|
|
||||||
t.Cleanup(func() {
|
|
||||||
clearDownloadCancel("test-item")
|
|
||||||
runtime.clearActiveDownloadItemID()
|
|
||||||
})
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewRequest failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req = runtime.bindDownloadCancelContext(req)
|
|
||||||
cancelDownload("test-item")
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-req.Context().Done():
|
|
||||||
case <-time.After(500 * time.Millisecond):
|
|
||||||
t.Fatal("Expected bound request context to be cancelled")
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Context().Err() == nil {
|
|
||||||
t.Fatal("Expected request context error after cancellation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtensionRuntime_BindDownloadCancelContextPreservesPreCancelledState(t *testing.T) {
|
|
||||||
ext := &loadedExtension{
|
|
||||||
ID: "test-ext",
|
|
||||||
Manifest: &ExtensionManifest{
|
|
||||||
Name: "test-ext",
|
|
||||||
},
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime := newExtensionRuntime(ext)
|
|
||||||
runtime.setActiveDownloadItemID("test-item")
|
|
||||||
cancelDownload("test-item")
|
|
||||||
t.Cleanup(func() {
|
|
||||||
clearDownloadCancel("test-item")
|
|
||||||
runtime.clearActiveDownloadItemID()
|
|
||||||
})
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewRequest failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req = runtime.bindDownloadCancelContext(req)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-req.Context().Done():
|
|
||||||
case <-time.After(500 * time.Millisecond):
|
|
||||||
t.Fatal("Expected pre-cancelled request context to stay cancelled")
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Context().Err() == nil {
|
|
||||||
t.Fatal("Expected request context error for pre-cancelled item")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunWithTimeoutContextCancelsExecution(t *testing.T) {
|
|
||||||
vm := goja.New()
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
_, err := RunWithTimeoutContextAndRecover(ctx, vm, `while (true) {}`, 5*time.Second)
|
|
||||||
if !errors.Is(err, ErrExtensionRequestCancelled) {
|
|
||||||
t.Fatalf("expected extension request cancellation, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtensionRuntime_BindExtensionRequestCancelContext(t *testing.T) {
|
|
||||||
ext := &loadedExtension{
|
|
||||||
ID: "test-ext",
|
|
||||||
Manifest: &ExtensionManifest{
|
|
||||||
Name: "test-ext",
|
|
||||||
},
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
}
|
|
||||||
runtime := newExtensionRuntime(ext)
|
|
||||||
|
|
||||||
const requestID = "test-extension-request"
|
|
||||||
clearExtensionRequestCancel(requestID)
|
|
||||||
defer clearExtensionRequestCancel(requestID)
|
|
||||||
|
|
||||||
runtime.setActiveRequestID(requestID)
|
|
||||||
defer runtime.clearActiveRequestID()
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("new request: %v", err)
|
|
||||||
}
|
|
||||||
req = runtime.bindDownloadCancelContext(req)
|
|
||||||
|
|
||||||
cancelExtensionRequest(requestID)
|
|
||||||
select {
|
|
||||||
case <-req.Context().Done():
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("expected request context to be cancelled")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||||
ext := &loadedExtension{
|
// Create extension with limited network permissions
|
||||||
|
ext := &LoadedExtension{
|
||||||
ID: "test-ext",
|
ID: "test-ext",
|
||||||
Manifest: &ExtensionManifest{
|
Manifest: &ExtensionManifest{
|
||||||
Name: "test-ext",
|
Name: "test-ext",
|
||||||
@@ -442,7 +254,7 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
|||||||
DataDir: t.TempDir(),
|
DataDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := newExtensionRuntime(ext)
|
runtime := NewExtensionRuntime(ext)
|
||||||
|
|
||||||
privateIPs := []string{
|
privateIPs := []string{
|
||||||
"http://localhost/admin",
|
"http://localhost/admin",
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ func (e *JSExecutionError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||||
return RunWithTimeoutContext(context.Background(), vm, script, timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
|
||||||
if vm == nil {
|
if vm == nil {
|
||||||
return nil, fmt.Errorf("extension runtime unavailable")
|
return nil, fmt.Errorf("extension runtime unavailable")
|
||||||
}
|
}
|
||||||
@@ -32,10 +28,7 @@ func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string,
|
|||||||
timeout = DefaultJSTimeout
|
timeout = DefaultJSTimeout
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctx == nil {
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
ctx = context.Background()
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
type result struct {
|
type result struct {
|
||||||
@@ -60,7 +53,7 @@ func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string,
|
|||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
}}
|
}}
|
||||||
} else {
|
} else {
|
||||||
GoLog("[extensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
|
GoLog("[ExtensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
|
||||||
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,16 +67,11 @@ func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string,
|
|||||||
case res := <-resultCh:
|
case res := <-resultCh:
|
||||||
return res.value, res.err
|
return res.value, res.err
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
cancelled := ctx.Err() == context.Canceled
|
|
||||||
interruptMu.Lock()
|
interruptMu.Lock()
|
||||||
interrupted = true
|
interrupted = true
|
||||||
interruptMu.Unlock()
|
interruptMu.Unlock()
|
||||||
|
|
||||||
if cancelled {
|
vm.Interrupt("execution timeout")
|
||||||
vm.Interrupt("extension request cancelled")
|
|
||||||
} else {
|
|
||||||
vm.Interrupt("execution timeout")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MUST wait for the goroutine to finish before returning.
|
// MUST wait for the goroutine to finish before returning.
|
||||||
// The Goja VM is NOT thread-safe — if we return while the goroutine
|
// The Goja VM is NOT thread-safe — if we return while the goroutine
|
||||||
@@ -92,9 +80,6 @@ func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string,
|
|||||||
// pointer dereference.
|
// pointer dereference.
|
||||||
select {
|
select {
|
||||||
case res := <-resultCh:
|
case res := <-resultCh:
|
||||||
if cancelled {
|
|
||||||
return nil, ErrExtensionRequestCancelled
|
|
||||||
}
|
|
||||||
if res.err != nil {
|
if res.err != nil {
|
||||||
return nil, res.err
|
return nil, res.err
|
||||||
}
|
}
|
||||||
@@ -105,10 +90,7 @@ func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string,
|
|||||||
case <-time.After(60 * time.Second):
|
case <-time.After(60 * time.Second):
|
||||||
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
|
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
|
||||||
// Log a warning — the VM should NOT be reused after this.
|
// 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")
|
GoLog("[ExtensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
|
||||||
if cancelled {
|
|
||||||
return nil, ErrExtensionRequestCancelled
|
|
||||||
}
|
|
||||||
return nil, &JSExecutionError{
|
return nil, &JSExecutionError{
|
||||||
Message: "execution timeout exceeded (force)",
|
Message: "execution timeout exceeded (force)",
|
||||||
IsTimeout: true,
|
IsTimeout: true,
|
||||||
@@ -120,11 +102,7 @@ func RunWithTimeoutContext(ctx context.Context, vm *goja.Runtime, script string,
|
|||||||
// RunWithTimeoutAndRecover runs JS with timeout and clears interrupt state after
|
// RunWithTimeoutAndRecover runs JS with timeout and clears interrupt state after
|
||||||
// This should be used when you want to continue using the VM after a timeout
|
// This should be used when you want to continue using the VM after a timeout
|
||||||
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||||
return RunWithTimeoutContextAndRecover(context.Background(), vm, script, timeout)
|
result, err := RunWithTimeout(vm, script, timeout)
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
if vm != nil {
|
||||||
vm.ClearInterrupt()
|
vm.ClearInterrupt()
|
||||||
|
|||||||
@@ -6,79 +6,35 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
"unicode/utf8"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||||
multiUnderscore = regexp.MustCompile(`_+`)
|
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:([^{}]+)\}`)
|
dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`)
|
||||||
yearPattern = regexp.MustCompile(`\d{4}`)
|
yearPattern = regexp.MustCompile(`\d{4}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
func sanitizeFilename(filename string) string {
|
func sanitizeFilename(filename string) string {
|
||||||
sanitized := strings.ReplaceAll(filename, "/", " ")
|
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
||||||
sanitized = invalidChars.ReplaceAllString(sanitized, " ")
|
|
||||||
|
|
||||||
var builder strings.Builder
|
|
||||||
for _, r := range sanitized {
|
|
||||||
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r == 0x7F {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
builder.WriteRune(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
sanitized = builder.String()
|
|
||||||
sanitized = strings.TrimSpace(sanitized)
|
sanitized = strings.TrimSpace(sanitized)
|
||||||
sanitized = strings.Trim(sanitized, ". ")
|
sanitized = strings.Trim(sanitized, ".")
|
||||||
sanitized = strings.Join(strings.Fields(sanitized), " ")
|
|
||||||
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
|
||||||
sanitized = strings.Trim(sanitized, "_ ")
|
|
||||||
|
|
||||||
if !utf8.ValidString(sanitized) {
|
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
||||||
sanitized = strings.ToValidUTF8(sanitized, "_")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(sanitized) > 200 {
|
if len(sanitized) > 200 {
|
||||||
sanitized = truncateUTF8Bytes(sanitized, 200)
|
sanitized = sanitized[:200]
|
||||||
sanitized = strings.TrimSpace(strings.Trim(sanitized, ". "))
|
|
||||||
sanitized = strings.Trim(sanitized, "_ ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if sanitized == "" {
|
if sanitized == "" {
|
||||||
return "Unknown"
|
sanitized = "untitled"
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncateUTF8Bytes(value string, maxBytes int) string {
|
|
||||||
if maxBytes <= 0 || len(value) <= maxBytes {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
used := 0
|
|
||||||
for i, r := range value {
|
|
||||||
runeLen := utf8.RuneLen(r)
|
|
||||||
if runeLen < 0 {
|
|
||||||
runeLen = len(string(r))
|
|
||||||
}
|
|
||||||
if used+runeLen > maxBytes {
|
|
||||||
return value[:i]
|
|
||||||
}
|
|
||||||
used += runeLen
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
|
func buildFilenameFromTemplate(template string, metadata map[string]interface{}) string {
|
||||||
if template == "" {
|
if template == "" {
|
||||||
template = "{artist} - {title}"
|
template = "{artist} - {title}"
|
||||||
@@ -99,11 +55,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
|||||||
"{album}": getString(metadata, "album"),
|
"{album}": getString(metadata, "album"),
|
||||||
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
||||||
"{track_raw}": formatRawNumber(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,
|
"{year}": yearValue,
|
||||||
"{date}": dateValue,
|
"{date}": dateValue,
|
||||||
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
||||||
@@ -125,9 +76,6 @@ func replaceFormattedNumberPlaceholders(template string, metadata map[string]int
|
|||||||
}
|
}
|
||||||
|
|
||||||
number := getInt(metadata, parts[1])
|
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])
|
width, err := strconv.Atoi(parts[2])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -185,8 +133,6 @@ func getInt(m map[string]interface{}, key string) int {
|
|||||||
candidateKeys = append(candidateKeys, "track_number")
|
candidateKeys = append(candidateKeys, "track_number")
|
||||||
case "disc":
|
case "disc":
|
||||||
candidateKeys = append(candidateKeys, "disc_number")
|
candidateKeys = append(candidateKeys, "disc_number")
|
||||||
case "playlist_position", "playlistPosition", "playlist position", "position":
|
|
||||||
candidateKeys = append(candidateKeys, "playlistPosition", "playlist position", "position")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, candidate := range candidateKeys {
|
for _, candidate := range candidateKeys {
|
||||||
@@ -210,10 +156,6 @@ func getInt(m map[string]interface{}, key string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPlaylistPosition(metadata map[string]interface{}) int {
|
|
||||||
return getInt(metadata, "playlist_position")
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatTrackNumber(n int) string {
|
func formatTrackNumber(n int) string {
|
||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||