Compare commits
192 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb4cd75cb2 | |||
| 8b7cecc1c5 | |||
| 012dcdc2dd | |||
| 629eb66595 | |||
| 36749a40d3 | |||
| 4336e6dc78 | |||
| 3e3e87e73e | |||
| 1b8d6ce7fa | |||
| 60f1df1488 | |||
| ff86869c33 | |||
| 2a2d817314 | |||
| 7845ac8be5 | |||
| 81547013f9 | |||
| 8e605cbd0f | |||
| d664d46ca4 | |||
| b4031936a0 | |||
| f84a33bbf2 | |||
| 8f5c59683a | |||
| 4b7146afe4 | |||
| 939407675b | |||
| 20ac6b2cd4 | |||
| 904b45e8f6 | |||
| 1bd54c530b | |||
| fb5d8826a2 | |||
| 4bc28704ff | |||
| ed7171133f | |||
| 67885e17ed | |||
| fd4da1b7c4 | |||
| 242a57b7eb | |||
| 18467c54d6 | |||
| 8238e2fe68 | |||
| 13c2360b7e | |||
| f1138ec7af | |||
| 0e00660e2e | |||
| aad72226c5 | |||
| 83d7106e35 | |||
| 30a7cba02a | |||
| 01a5b43613 | |||
| 149cdc782d | |||
| d24435dbc2 | |||
| bb06ab7e12 | |||
| 2143de3aa7 | |||
| b5973c45a2 | |||
| 9a78798854 | |||
| 101ab3f521 | |||
| cfc8e699f3 | |||
| 6b342aeac6 | |||
| b306056995 | |||
| 82e317c4a8 | |||
| a4dc776bfb | |||
| 5bdaa35ced | |||
| e187ac461d | |||
| 1b4a6cd042 | |||
| dcfb22c3f4 | |||
| 501158df03 | |||
| e17a4fad4e | |||
| 34894faabf | |||
| b329acd710 | |||
| 87dc8eb5ea | |||
| 397669965d | |||
| 60bd0e619e | |||
| 2c7621c1a5 | |||
| b55be00fab | |||
| f8b7812943 | |||
| 8f14ff169a | |||
| ca3abeb1cf | |||
| bb0cc23461 | |||
| 45fa33e1ec | |||
| 64dbf4441c | |||
| 148e5c1231 | |||
| 3a7419ec9f | |||
| 01c7c9cc3a | |||
| 3f56b88fa5 | |||
| bdd3f4aef5 | |||
| 611abdc6ae | |||
| 6e9fa45915 | |||
| 7dafbc1063 | |||
| ad8ac3bd2b | |||
| cd2c2a9854 | |||
| bb7c86c29e | |||
| df96cc4a1d | |||
| 6c3d92cee4 | |||
| 803cd2de96 | |||
| 8f2ca33e87 | |||
| d87e0d7e01 | |||
| 86b8709ea1 | |||
| 702b917929 | |||
| 16ce6089fb | |||
| 6895e45f2c | |||
| e87f7a1177 | |||
| bcd8a05352 | |||
| 4b219ad18e | |||
| 57051bd649 | |||
| d6fca6ca55 | |||
| 153ec2d9e5 | |||
| be90e85d94 | |||
| 4af089f56c | |||
| 62519d2d1c | |||
| 27c0880e87 | |||
| f312b74b30 | |||
| bd49e307ef | |||
| e904a836c1 | |||
| 763c9478f1 | |||
| 427bdf74dc | |||
| 373a276c54 | |||
| dccadf1f87 | |||
| d9933fe038 | |||
| d47ac0934d | |||
| dbba4d6630 | |||
| 7405855e01 | |||
| ed020c9303 | |||
| 378742e37a | |||
| c79bee534e | |||
| 1d6df75829 | |||
| b7f51b5f14 | |||
| 1c8e9df727 | |||
| 01540fe3fc | |||
| 071db2f109 | |||
| e097d3f605 | |||
| 277f783f62 | |||
| 7637aaf168 | |||
| c4878470bf | |||
| a3725e8c48 | |||
| 917ba842f5 | |||
| dac17ead33 | |||
| 6845ebe04c | |||
| eff709480d | |||
| 67833424cc | |||
| 5c48e1b476 | |||
| 5e17c9f238 | |||
| 7d330fb2ec | |||
| cd6a4594fa | |||
| bcf727f4ec | |||
| 4c4553913f | |||
| f0013fac16 | |||
| ce4be0ba97 | |||
| 4bac38ef2a | |||
| 4b213f47d9 | |||
| a1010f72f2 | |||
| 21077a26d0 | |||
| b50eec5a47 | |||
| 38a8b715f8 | |||
| 2b47537bb5 | |||
| a5cf241846 | |||
| 53a4773480 | |||
| 89603af1f1 | |||
| 2143084d3c | |||
| 0e265193b8 | |||
| c7e9749ce4 | |||
| e21cffff0b | |||
| d9e20040be | |||
| 6689173525 | |||
| f37e4704a6 | |||
| 65dbd5c8e4 | |||
| d034144e9c | |||
| 7c4309955e | |||
| 63e90d13d4 | |||
| bfb0cad603 | |||
| cc10a917dc | |||
| 5e833c1f75 | |||
| 8c576ac7e4 | |||
| 92160537c0 | |||
| 120ecaa0e5 | |||
| fd3a34303e | |||
| d89b70e155 | |||
| e3b63c1d27 | |||
| 96301c0dbf | |||
| a2458c1292 | |||
| 1737e12dd2 | |||
| b770d7d9ca | |||
| b712b9f509 | |||
| 51496cd34e | |||
| 2b2c2bc90a | |||
| e2a489ec92 | |||
| 4f46dd947d | |||
| fbb8d30db0 | |||
| c0637006af | |||
| 3fc371b8c4 | |||
| ee5b3824e9 | |||
| be6a856773 | |||
| e41c299d49 | |||
| 981786b4a2 | |||
| eeb6f11808 | |||
| 8e361e14b4 | |||
| d58d46eb1f | |||
| 562a17f7ae | |||
| b035e66540 | |||
| 38792a753e | |||
| d5b34b4f15 | |||
| 2a45c8dcdb | |||
| e7a2166a4f | |||
| f54597e655 |
@@ -44,6 +44,7 @@ go_backend/*.xcframework/
|
||||
# Android
|
||||
android/.gradle/
|
||||
android/app/libs/gobackend.aar
|
||||
android/app/libs/gobackend-sources.jar
|
||||
android/local.properties
|
||||
android/*.iml
|
||||
android/key.properties
|
||||
@@ -57,7 +58,6 @@ ios/Pods/
|
||||
ios/.symlinks/
|
||||
ios/Flutter/Flutter.framework/
|
||||
ios/Flutter/Flutter.podspec
|
||||
android/app/libs/gobackend-sources.jar
|
||||
|
||||
# Extension folder
|
||||
extension/
|
||||
@@ -67,7 +67,10 @@ AGENTS.md
|
||||
|
||||
# Temp/misc
|
||||
nul
|
||||
NUL
|
||||
network_requests.txt
|
||||
*.bak
|
||||
/AndroidManifest.xml
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
|
||||
@@ -86,7 +86,7 @@ Translation files are located in `lib/l10n/arb/`.
|
||||
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||
```
|
||||
|
||||
3. **Use FVM (Flutter Version: 3.38.1)**
|
||||
3. **Use FVM (Flutter Version: 3.41.5)**
|
||||
```bash
|
||||
fvm use
|
||||
```
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
|
||||
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/readme/banner-readme-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/readme/banner-readme-light.png">
|
||||
<img alt="SpotiFLAC Mobile" src="assets/readme/banner-readme-light.png" width="650" height="auto">
|
||||
</picture>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/17247">
|
||||
<img src="https://trendshift.io/api/badge/repositories/17247" alt="zarzet%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
||||
<a href="https://trendshift.io/repositories/25971" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/25971" alt="spotiflacapp%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -28,10 +28,10 @@
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/images/1.jpg?v=2" width="200" />
|
||||
<img src="assets/images/2.jpg?v=2" width="200" />
|
||||
<img src="assets/images/3.jpg?v=2" width="200" />
|
||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/1.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/2.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/3.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/4.jpg?v=2" width="200" />
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -166,9 +166,8 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md)
|
||||
|
||||
| | | | | |
|
||||
|---|---|---|---|---|
|
||||
| [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) |
|
||||
| [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) | |
|
||||
| [MusicDL](https://www.musicdl.me) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) | [Song.link](https://song.link) |
|
||||
| [IDHS](https://github.com/sjdonado/idonthavespotify) | | | | |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,11 @@ linter:
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
always_declare_return_types: true
|
||||
avoid_dynamic_calls: true
|
||||
avoid_types_as_parameter_names: true
|
||||
strict_top_level_inference: true
|
||||
type_annotate_public_apis: true
|
||||
cancel_subscriptions: true
|
||||
close_sinks: true
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
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'
|
||||
}
|
||||
@@ -20,6 +20,10 @@ android {
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
@@ -119,5 +123,6 @@ dependencies {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||
implementation("androidx.activity:activity-ktx:1.12.3")
|
||||
implementation("androidx.activity:activity-ktx:1.13.0")
|
||||
implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0")
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<application
|
||||
android:label="SpotiFLAC"
|
||||
android:label="SpotiFLAC Mobile"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="false"
|
||||
@@ -86,6 +86,20 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="music.youtube.com" />
|
||||
</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>
|
||||
</activity>
|
||||
|
||||
<!-- Download Service -->
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.example.temp_project
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -4,6 +4,7 @@ import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
@@ -42,6 +43,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"com.zarz.spotiflac/library_scan_progress_stream"
|
||||
private val DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS = 1200L
|
||||
private val LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS = 200L
|
||||
private val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
|
||||
private val LARGE_JSON_RESULT_FILE_KEY = "__json_file"
|
||||
private val LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var pendingSafTreeResult: MethodChannel.Result? = null
|
||||
private val safScanLock = Any()
|
||||
@@ -50,6 +54,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
private var downloadProgressStreamJob: Job? = null
|
||||
private var downloadProgressEventSink: EventChannel.EventSink? = null
|
||||
private var lastDownloadProgressPayload: String? = null
|
||||
private var lastDownloadProgressSeq = 0L
|
||||
private var libraryScanProgressStreamJob: Job? = null
|
||||
private var libraryScanProgressEventSink: EventChannel.EventSink? = null
|
||||
private var lastLibraryScanProgressPayload: String? = null
|
||||
@@ -298,7 +303,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
private fun mimeTypeForExt(ext: String?): String {
|
||||
return when (normalizeExt(ext)) {
|
||||
".m4a" -> "audio/mp4"
|
||||
".m4a", ".mp4" -> "audio/mp4"
|
||||
".mp3" -> "audio/mpeg"
|
||||
".opus" -> "audio/ogg"
|
||||
".flac" -> "audio/flac"
|
||||
@@ -308,7 +313,62 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
|
||||
private fun sanitizeFilename(name: String): String {
|
||||
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim()
|
||||
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 {
|
||||
@@ -368,6 +428,43 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
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
|
||||
}
|
||||
|
||||
// SAF can auto-rename to "name (1)" when another writer wins the race
|
||||
// between findFile() and createFile(). Prefer the exact sibling if it
|
||||
// appeared, and discard the duplicate document we just 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 resetSafScanProgress() {
|
||||
synchronized(safScanLock) {
|
||||
safScanProgress = SafScanProgress()
|
||||
@@ -434,17 +531,46 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun bridgeJsonResult(payload: String): Any {
|
||||
if (payload.toByteArray(Charsets.UTF_8).size < LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES) {
|
||||
return payload
|
||||
}
|
||||
|
||||
return try {
|
||||
val file = File(cacheDir, "bridge_json_${System.nanoTime()}.json")
|
||||
file.writeText(payload, Charsets.UTF_8)
|
||||
mapOf(LARGE_JSON_RESULT_FILE_KEY to file.absolutePath)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"SpotiFLAC",
|
||||
"Failed to spill large bridge JSON result to file: ${e.message}",
|
||||
)
|
||||
payload
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDownloadProgressSeq(payload: String) {
|
||||
try {
|
||||
val seq = JSONObject(payload).optLong("seq", lastDownloadProgressSeq)
|
||||
if (seq > lastDownloadProgressSeq) {
|
||||
lastDownloadProgressSeq = seq
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun startDownloadProgressStream(sink: EventChannel.EventSink) {
|
||||
stopDownloadProgressStream()
|
||||
downloadProgressEventSink = sink
|
||||
lastDownloadProgressPayload = null
|
||||
lastDownloadProgressSeq = 0L
|
||||
downloadProgressStreamJob = scope.launch {
|
||||
while (isActive && downloadProgressEventSink === sink) {
|
||||
try {
|
||||
val payload = withContext(Dispatchers.IO) {
|
||||
Gobackend.getAllDownloadProgress()
|
||||
Gobackend.getAllDownloadProgressDelta(lastDownloadProgressSeq)
|
||||
}
|
||||
if (payload != lastDownloadProgressPayload) {
|
||||
if (payload.isNotEmpty() && payload != lastDownloadProgressPayload) {
|
||||
updateDownloadProgressSeq(payload)
|
||||
lastDownloadProgressPayload = payload
|
||||
sink.success(parseJsonPayload(payload))
|
||||
}
|
||||
@@ -464,6 +590,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
downloadProgressStreamJob = null
|
||||
downloadProgressEventSink = null
|
||||
lastDownloadProgressPayload = null
|
||||
lastDownloadProgressSeq = 0L
|
||||
}
|
||||
|
||||
private fun startLibraryScanProgressStream(sink: EventChannel.EventSink) {
|
||||
@@ -510,17 +637,17 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
lastLibraryScanProgressPayload = null
|
||||
}
|
||||
|
||||
private fun loadExistingFilesJsonFromSnapshot(snapshotPath: String): String {
|
||||
private fun loadExistingFilesFromSnapshot(snapshotPath: String): MutableMap<String, Long> {
|
||||
val result = mutableMapOf<String, Long>()
|
||||
if (snapshotPath.isBlank()) {
|
||||
return "{}"
|
||||
return result
|
||||
}
|
||||
|
||||
val snapshotFile = File(snapshotPath)
|
||||
if (!snapshotFile.exists()) {
|
||||
return "{}"
|
||||
return result
|
||||
}
|
||||
|
||||
val result = JSONObject()
|
||||
snapshotFile.forEachLine { line ->
|
||||
if (line.isBlank()) return@forEachLine
|
||||
val separatorIndex = line.indexOf('\t')
|
||||
@@ -530,10 +657,10 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val modTime = line.substring(0, separatorIndex).toLongOrNull() ?: 0L
|
||||
val filePath = line.substring(separatorIndex + 1)
|
||||
if (filePath.isNotEmpty()) {
|
||||
result.put(filePath, modTime)
|
||||
result[filePath] = modTime
|
||||
}
|
||||
}
|
||||
return result.toString()
|
||||
return result
|
||||
}
|
||||
|
||||
private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String {
|
||||
@@ -597,16 +724,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return obj.toString()
|
||||
}
|
||||
|
||||
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
|
||||
val provided = req.optString("saf_file_name", "")
|
||||
if (provided.isNotBlank()) return sanitizeFilename(provided)
|
||||
|
||||
val trackName = req.optString("track_name", "track")
|
||||
val artistName = req.optString("artist_name", "")
|
||||
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
|
||||
return sanitizeFilename(baseName) + outputExt
|
||||
}
|
||||
|
||||
private fun errorJson(message: String): String {
|
||||
val obj = JSONObject()
|
||||
obj.put("success", false)
|
||||
@@ -654,6 +771,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
private fun extFromFileName(name: String): String {
|
||||
return when {
|
||||
name.endsWith(".m4a") -> ".m4a"
|
||||
name.endsWith(".mp4") -> ".mp4"
|
||||
name.endsWith(".aac") -> ".aac"
|
||||
name.endsWith(".mp3") -> ".mp3"
|
||||
name.endsWith(".opus") -> ".opus"
|
||||
name.endsWith(".flac") -> ".flac"
|
||||
@@ -665,6 +784,10 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
private fun extFromMimeType(mime: String?): String {
|
||||
return when (mime) {
|
||||
"audio/mp4" -> ".m4a"
|
||||
"audio/aac" -> ".aac"
|
||||
"audio/eac3" -> ".m4a"
|
||||
"audio/ac3" -> ".m4a"
|
||||
"audio/ac4" -> ".m4a"
|
||||
"audio/mpeg" -> ".mp3"
|
||||
"audio/ogg" -> ".opus"
|
||||
"audio/flac" -> ".flac"
|
||||
@@ -887,98 +1010,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return true
|
||||
}
|
||||
|
||||
private fun handleSafDownload(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 existingDir = findDocumentDir(treeUri, relativeDir)
|
||||
if (existingDir != null) {
|
||||
val existing = existingDir.findFile(fileName)
|
||||
if (existing != null && existing.isFile && existing.length() > 0) {
|
||||
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(treeUri, relativeDir)
|
||||
?: return errorJson("Failed to access SAF directory")
|
||||
|
||||
val existingFile = targetDir.findFile(fileName)
|
||||
val document = existingFile ?: targetDir.createFile(mimeType, fileName)
|
||||
?: return errorJson("Failed to create SAF file")
|
||||
|
||||
val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
|
||||
?: return errorJson("Failed to open SAF file")
|
||||
|
||||
var detachedFd: Int? = null
|
||||
try {
|
||||
// Prefer handing off a detached FD directly to Go.
|
||||
// Some devices/providers reject re-opening /proc/self/fd/* with permission denied.
|
||||
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)) {
|
||||
// Extension providers write to a local temp path instead of the SAF FD.
|
||||
val goFilePath = respObj.optString("file_path", "")
|
||||
if (goFilePath.isNotEmpty() &&
|
||||
!goFilePath.startsWith("content://") &&
|
||||
!goFilePath.startsWith("/proc/self/fd/")
|
||||
) {
|
||||
try {
|
||||
val srcFile = java.io.File(goFilePath)
|
||||
if (!srcFile.exists() || srcFile.length() <= 0) {
|
||||
throw IllegalStateException("extension output missing or empty: $goFilePath")
|
||||
}
|
||||
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)
|
||||
} else {
|
||||
document.delete()
|
||||
}
|
||||
return respObj.toString()
|
||||
} catch (e: Exception) {
|
||||
document.delete()
|
||||
return errorJson("SAF download failed: ${e.message}")
|
||||
} finally {
|
||||
// If detachFd() failed before handoff, close original ParcelFileDescriptor.
|
||||
// Otherwise Go owns the detached raw FD and is responsible for closing it.
|
||||
if (detachedFd == null) {
|
||||
try {
|
||||
pfd.close()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent DocumentFile directory for a SAF document URI.
|
||||
* The child URI must be a tree-based document URI (e.g. from SAF tree scan).
|
||||
@@ -1037,7 +1068,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
|
||||
private val cueSiblingAudioExtensions = listOf(
|
||||
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"
|
||||
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac"
|
||||
)
|
||||
|
||||
private fun getSafChildFileLookup(
|
||||
@@ -1109,7 +1140,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
it.currentFile = "Scanning folders..."
|
||||
}
|
||||
|
||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
|
||||
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
|
||||
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||
val visitedDirUris = mutableSetOf<String>()
|
||||
@@ -1367,6 +1398,22 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
* @return JSON object with new/changed files and removed URIs
|
||||
*/
|
||||
private fun scanSafTreeIncremental(treeUriStr: String, existingFilesJson: String): String {
|
||||
val existingFiles = mutableMapOf<String, Long>()
|
||||
try {
|
||||
val obj = JSONObject(existingFilesJson)
|
||||
val keys = obj.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
existingFiles[key] = obj.optLong(key, 0)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
return scanSafTreeIncremental(treeUriStr, existingFiles)
|
||||
}
|
||||
|
||||
private fun scanSafTreeIncremental(
|
||||
treeUriStr: String,
|
||||
existingFiles: Map<String, Long>,
|
||||
): String {
|
||||
if (treeUriStr.isBlank()) {
|
||||
val result = JSONObject()
|
||||
result.put("files", JSONArray())
|
||||
@@ -1386,16 +1433,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
val existingFiles = mutableMapOf<String, Long>()
|
||||
try {
|
||||
val obj = JSONObject(existingFilesJson)
|
||||
val keys = obj.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
existingFiles[key] = obj.optLong(key, 0)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
resetSafScanProgress()
|
||||
safScanCancel = false
|
||||
safScanActive = true
|
||||
@@ -1403,7 +1440,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
it.currentFile = "Scanning folders..."
|
||||
}
|
||||
|
||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
|
||||
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
|
||||
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
|
||||
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||
@@ -1934,9 +1971,54 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
// We handle these URLs ourselves via receive_sharing_intent + ShareIntentService.
|
||||
override fun shouldHandleDeeplinking(): Boolean = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
handleExtensionOAuthIntent(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleExtensionOAuthIntent(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver Spotify (or other) OAuth authorization code to the extension runtime
|
||||
* and run its token exchange (e.g. completeSpotifyLogin). State must be the extension id.
|
||||
*/
|
||||
private fun handleExtensionOAuthIntent(intent: Intent?) {
|
||||
val uri = intent?.data ?: return
|
||||
if (!uri.scheme.equals("spotiflac", ignoreCase = true)) {
|
||||
return
|
||||
}
|
||||
val host = (uri.host ?: "").lowercase(Locale.US)
|
||||
val path = (uri.path ?: "").lowercase(Locale.US)
|
||||
val isCallback =
|
||||
host == "callback" ||
|
||||
host == "spotify-callback" ||
|
||||
path.contains("callback")
|
||||
if (!isCallback) {
|
||||
return
|
||||
}
|
||||
val code = uri.getQueryParameter("code")?.trim().orEmpty()
|
||||
if (code.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val extId = uri.getQueryParameter("state")?.trim().orEmpty()
|
||||
if (extId.isEmpty()) {
|
||||
android.util.Log.w("SpotiFLAC", "Extension OAuth redirect missing state (extension id)")
|
||||
return
|
||||
}
|
||||
intent.data = null
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Gobackend.setExtensionAuthCodeByID(extId, code)
|
||||
val json = Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin")
|
||||
android.util.Log.i("SpotiFLAC", "Extension OAuth complete for $extId: $json")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "Extension OAuth failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
@@ -1952,6 +2034,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
Gobackend.setAppVersion(BuildConfig.VERSION_NAME)
|
||||
|
||||
// Always-enabled back callback to ensure back presses reach Flutter.
|
||||
// Nested tab navigators can incorrectly set frameworkHandlesBack(false),
|
||||
@@ -2025,7 +2108,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"downloadByStrategy" -> {
|
||||
val requestJson = call.arguments as String
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
handleSafDownload(requestJson) { json ->
|
||||
SafDownloadHandler.handle(this@MainActivity, requestJson) { json ->
|
||||
Gobackend.downloadByStrategy(json)
|
||||
}
|
||||
}
|
||||
@@ -2136,7 +2219,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
result.error("saf_pending", "SAF picker already active", null)
|
||||
return@launch
|
||||
}
|
||||
pendingSafTreeResult = result
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
intent.addFlags(
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
@@ -2144,7 +2226,24 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
|
||||
)
|
||||
safTreeLauncher.launch(intent)
|
||||
val resolver = intent.resolveActivity(packageManager)
|
||||
if (resolver == null) {
|
||||
result.error("saf_unavailable", "No folder picker available on this device", null)
|
||||
return@launch
|
||||
}
|
||||
pendingSafTreeResult = result
|
||||
try {
|
||||
android.util.Log.i("SpotiFLAC", "Launching SAF picker via $resolver")
|
||||
safTreeLauncher.launch(intent)
|
||||
} catch (e: Exception) {
|
||||
pendingSafTreeResult = null
|
||||
android.util.Log.e("SpotiFLAC", "Failed to launch SAF picker: ${e.message}", e)
|
||||
result.error(
|
||||
"saf_launch_failed",
|
||||
e.message ?: "Failed to launch folder picker",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
"safExists" -> {
|
||||
val uriStr = call.argument<String>("uri") ?: ""
|
||||
@@ -2219,7 +2318,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
|
||||
val existing = dir.findFile(fileName)
|
||||
val createdNew = existing == null
|
||||
val doc = existing ?: dir.createFile(mimeType, fileName) ?: return@withContext null
|
||||
val doc = createOrReuseDocumentFile(dir, mimeType, fileName)
|
||||
?: return@withContext null
|
||||
if (!writeUriFromPath(doc.uri, srcPath)) {
|
||||
if (createdNew) {
|
||||
doc.delete()
|
||||
@@ -2689,15 +2789,47 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"updateDownloadServiceProgress" -> {
|
||||
val trackName = call.argument<String>("track_name") ?: ""
|
||||
val artistName = call.argument<String>("artist_name") ?: ""
|
||||
val progress = call.argument<Long>("progress") ?: 0L
|
||||
val total = call.argument<Long>("total") ?: 0L
|
||||
val queueCount = call.argument<Int>("queue_count") ?: 0
|
||||
DownloadService.updateProgress(this@MainActivity, trackName, artistName, progress, total, queueCount)
|
||||
val progress = (call.argument<Number>("progress") ?: 0).toLong()
|
||||
val total = (call.argument<Number>("total") ?: 0).toLong()
|
||||
val queueCount = (call.argument<Number>("queue_count") ?: 0).toInt()
|
||||
val status = call.argument<String>("status") ?: "downloading"
|
||||
DownloadService.updateProgress(this@MainActivity, trackName, artistName, progress, total, queueCount, status)
|
||||
result.success(null)
|
||||
}
|
||||
"isDownloadServiceRunning" -> {
|
||||
result.success(DownloadService.isServiceRunning())
|
||||
}
|
||||
"startNativeDownloadWorker" -> {
|
||||
val requestsJson = call.argument<String>("requests_json") ?: "[]"
|
||||
val settingsJson = call.argument<String>("settings_json") ?: "{}"
|
||||
val requestsPath = call.argument<String>("requests_path") ?: ""
|
||||
val settingsPath = call.argument<String>("settings_path") ?: ""
|
||||
if (requestsPath.isNotBlank()) {
|
||||
DownloadService.startNativeQueueFromFiles(
|
||||
this@MainActivity,
|
||||
requestsPath,
|
||||
settingsPath
|
||||
)
|
||||
} else {
|
||||
DownloadService.startNativeQueue(this@MainActivity, requestsJson, settingsJson)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"pauseNativeDownloadWorker" -> {
|
||||
DownloadService.pauseNativeQueue(this@MainActivity)
|
||||
result.success(null)
|
||||
}
|
||||
"resumeNativeDownloadWorker" -> {
|
||||
DownloadService.resumeNativeQueue(this@MainActivity)
|
||||
result.success(null)
|
||||
}
|
||||
"cancelNativeDownloadWorker" -> {
|
||||
DownloadService.cancelNativeQueue(this@MainActivity)
|
||||
result.success(null)
|
||||
}
|
||||
"getNativeDownloadWorkerSnapshot" -> {
|
||||
result.success(parseJsonPayload(DownloadService.getNativeWorkerSnapshot(this@MainActivity)))
|
||||
}
|
||||
"preWarmTrackCache" -> {
|
||||
val tracksJson = call.argument<String>("tracks") ?: "[]"
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -2717,36 +2849,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"searchDeezerAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
val artistLimit = call.argument<Int>("artist_limit") ?: 2
|
||||
val filter = call.argument<String>("filter") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"searchTidalAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
val artistLimit = call.argument<Int>("artist_limit") ?: 2
|
||||
val filter = call.argument<String>("filter") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchTidalAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"searchQobuzAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
val artistLimit = call.argument<Int>("artist_limit") ?: 2
|
||||
val filter = call.argument<String>("filter") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchQobuzAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getDeezerRelatedArtists" -> {
|
||||
val artistId = call.argument<String>("artist_id") ?: ""
|
||||
val limit = call.argument<Int>("limit") ?: 12
|
||||
@@ -2755,62 +2857,20 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getDeezerMetadata" -> {
|
||||
"getProviderMetadata" -> {
|
||||
val providerId = call.argument<String>("provider_id") ?: ""
|
||||
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||
val resourceId = call.argument<String>("resource_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getDeezerMetadata(resourceType, resourceId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getQobuzMetadata" -> {
|
||||
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||
val resourceId = call.argument<String>("resource_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getQobuzMetadata(resourceType, resourceId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getTidalMetadata" -> {
|
||||
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||
val resourceId = call.argument<String>("resource_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getTidalMetadata(resourceType, resourceId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"parseDeezerUrl" -> {
|
||||
val url = call.argument<String>("url") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.parseDeezerURLExport(url)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"parseQobuzUrl" -> {
|
||||
val url = call.argument<String>("url") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.parseQobuzURLExport(url)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"parseTidalUrl" -> {
|
||||
val url = call.argument<String>("url") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.parseTidalURLExport(url)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"convertTidalToSpotifyDeezer" -> {
|
||||
val url = call.argument<String>("url") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.convertTidalToSpotifyDeezer(url)
|
||||
Gobackend.getProviderMetadataJSON(providerId, resourceType, resourceId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"searchDeezerByISRC" -> {
|
||||
val isrc = call.argument<String>("isrc") ?: ""
|
||||
val itemId = call.argument<String>("item_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchDeezerByISRC(isrc)
|
||||
Gobackend.searchDeezerByISRCForItemID(isrc, itemId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -2995,6 +3055,13 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"checkExtensionHealth" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.checkExtensionHealthJSON(extensionId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"setExtensionSettings" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val settingsJson = call.argument<String>("settings") ?: "{}"
|
||||
@@ -3122,11 +3189,19 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val optionsJson = call.argument<String>("options") ?: ""
|
||||
val requestId = call.argument<String>("request_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.customSearchWithExtensionJSON(extensionId, query, optionsJson)
|
||||
Gobackend.customSearchWithExtensionJSONWithRequestID(extensionId, query, optionsJson, requestId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"cancelExtensionRequest" -> {
|
||||
val requestId = call.argument<String>("request_id") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.cancelExtensionRequestJSON(requestId)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"getSearchProviders" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getSearchProvidersJSON()
|
||||
@@ -3153,30 +3228,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getAlbumWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val albumId = call.argument<String>("album_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getAlbumWithExtensionJSON(extensionId, albumId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getPlaylistWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val playlistId = call.argument<String>("playlist_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getPlaylistWithExtensionJSON(extensionId, playlistId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getArtistWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val artistId = call.argument<String>("artist_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getArtistWithExtensionJSON(extensionId, artistId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"runPostProcessing" -> {
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
val metadataJson = call.argument<String>("metadata") ?: ""
|
||||
@@ -3283,8 +3334,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
"getExtensionHomeFeed" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val requestId = call.argument<String>("request_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getExtensionHomeFeedJSON(extensionId)
|
||||
Gobackend.getExtensionHomeFeedJSONWithRequestID(extensionId, requestId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -3306,7 +3358,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val folderPath = call.argument<String>("folder_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
safScanActive = false
|
||||
Gobackend.scanLibraryFolderJSON(folderPath)
|
||||
bridgeJsonResult(Gobackend.scanLibraryFolderJSON(folderPath))
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -3315,7 +3367,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val existingFiles = call.argument<String>("existing_files") ?: "{}"
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
safScanActive = false
|
||||
Gobackend.scanLibraryFolderIncrementalJSON(folderPath, existingFiles)
|
||||
bridgeJsonResult(
|
||||
Gobackend.scanLibraryFolderIncrementalJSON(folderPath, existingFiles)
|
||||
)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -3324,9 +3378,11 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val snapshotPath = call.argument<String>("snapshot_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
safScanActive = false
|
||||
Gobackend.scanLibraryFolderIncrementalFromSnapshotJSON(
|
||||
folderPath,
|
||||
snapshotPath,
|
||||
bridgeJsonResult(
|
||||
Gobackend.scanLibraryFolderIncrementalFromSnapshotJSON(
|
||||
folderPath,
|
||||
snapshotPath,
|
||||
)
|
||||
)
|
||||
}
|
||||
result.success(response)
|
||||
@@ -3334,7 +3390,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"scanSafTree" -> {
|
||||
val treeUri = call.argument<String>("tree_uri") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
scanSafTree(treeUri)
|
||||
bridgeJsonResult(scanSafTree(treeUri))
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -3342,7 +3398,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val treeUri = call.argument<String>("tree_uri") ?: ""
|
||||
val existingFiles = call.argument<String>("existing_files") ?: "{}"
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
scanSafTreeIncremental(treeUri, existingFiles)
|
||||
bridgeJsonResult(scanSafTreeIncremental(treeUri, existingFiles))
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -3350,9 +3406,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val treeUri = call.argument<String>("tree_uri") ?: ""
|
||||
val snapshotPath = call.argument<String>("snapshot_path") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
val existingFilesJson =
|
||||
loadExistingFilesJsonFromSnapshot(snapshotPath)
|
||||
scanSafTreeIncremental(treeUri, existingFilesJson)
|
||||
val existingFiles =
|
||||
loadExistingFilesFromSnapshot(snapshotPath)
|
||||
bridgeJsonResult(scanSafTreeIncremental(treeUri, existingFiles))
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
@@ -3424,7 +3480,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
} catch (_: Exception) { "" }
|
||||
val cueBaseName = cueName.substringBeforeLast('.')
|
||||
if (cueBaseName.isNotBlank()) {
|
||||
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
|
||||
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac")
|
||||
for (ext in commonExts) {
|
||||
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
|
||||
if (audioDoc != null) break
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
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: 2.8 KiB After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -1,12 +1,4 @@
|
||||
<?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">
|
||||
<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>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -1,12 +1,4 @@
|
||||
<?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">
|
||||
<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>
|
||||
|
||||
@@ -6,4 +6,9 @@
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
<monochrome>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_monochrome"
|
||||
android:inset="16%" />
|
||||
</monochrome>
|
||||
</adaptive-icon>
|
||||
|
||||
|
Before Width: | Height: | Size: 954 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 647 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -1,17 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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">
|
||||
<!-- 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>
|
||||
</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">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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">
|
||||
<!-- 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>
|
||||
</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">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
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
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-all.zip
|
||||
|
||||
@@ -20,7 +20,7 @@ pluginManagement {
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.13.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "SpotiFLAC Source",
|
||||
"name": "SpotiFLAC Mobile Source",
|
||||
"identifier": "com.zarzet.spotiflac.source",
|
||||
"subtitle": "FLAC Downloader for iOS",
|
||||
"apps": [
|
||||
{
|
||||
"name": "SpotiFLAC",
|
||||
"name": "SpotiFLAC Mobile",
|
||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||
"developerName": "zarzet",
|
||||
"version": "3.9.0",
|
||||
"versionDate": "2026-03-25",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.9.0/SpotiFLAC-v3.9.0-ios-unsigned.ipa",
|
||||
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||
"version": "4.5.5",
|
||||
"versionDate": "2026-05-14",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.5/SpotiFLAC-v4.5.5-ios-unsigned.ipa",
|
||||
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||
"size": 34477323
|
||||
"size": 37191956
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 71 KiB |
|
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 |
@@ -6,6 +6,7 @@ files:
|
||||
# Short codes for single-variant languages
|
||||
de: de
|
||||
es: es
|
||||
es-ES: es_ES
|
||||
fr: fr
|
||||
hi: hi
|
||||
id: id
|
||||
@@ -13,7 +14,11 @@ files:
|
||||
ko: ko
|
||||
nl: nl
|
||||
pt: pt
|
||||
pt-PT: pt_PT
|
||||
ru: ru
|
||||
tr: tr
|
||||
uk: uk
|
||||
zh: zh
|
||||
# Full codes for Chinese variants
|
||||
zh-CN: zh_CN
|
||||
zh-TW: zh_TW
|
||||
|
||||
@@ -56,7 +56,6 @@ func ReadAPETags(filePath string) (*APETag, error) {
|
||||
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).
|
||||
tag, err := readAPETagAtOffset(f, fileSize, fileSize-apeTagHeaderSize)
|
||||
if err == nil {
|
||||
@@ -255,7 +254,6 @@ func findExistingAPETagSize(filePath string) (int64, error) {
|
||||
|
||||
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
|
||||
totalSize := tagSize
|
||||
if hasHeader {
|
||||
@@ -511,7 +509,6 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
||||
// deletion: the caller sends an empty value which is not serialized into
|
||||
// newItems, but the old value must still be dropped.
|
||||
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))
|
||||
for k := range overrideKeys {
|
||||
combined[strings.ToUpper(k)] = struct{}{}
|
||||
@@ -539,7 +536,6 @@ func ReadAPETagsFromReader(r io.ReaderAt, fileSize int64) (*APETag, error) {
|
||||
return nil, fmt.Errorf("file too small for APE tag")
|
||||
}
|
||||
|
||||
// Try footer at end of file
|
||||
footer := make([]byte, apeTagHeaderSize)
|
||||
if _, err := r.ReadAt(footer, fileSize-apeTagHeaderSize); err != nil {
|
||||
return nil, fmt.Errorf("failed to read APE footer: %w", err)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
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,14 +9,23 @@ import (
|
||||
// ErrDownloadCancelled is returned when a download is cancelled by the user.
|
||||
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 {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
canceled bool
|
||||
refs int
|
||||
}
|
||||
|
||||
var (
|
||||
cancelMu sync.Mutex
|
||||
cancelMap = make(map[string]*cancelEntry)
|
||||
|
||||
extensionRequestCancelMu sync.Mutex
|
||||
extensionRequestCancelMap = make(map[string]*cancelEntry)
|
||||
)
|
||||
|
||||
func initDownloadCancel(itemID string) context.Context {
|
||||
@@ -27,10 +36,25 @@ func initDownloadCancel(itemID string) context.Context {
|
||||
cancelMu.Lock()
|
||||
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())
|
||||
cancelMap[itemID] = &cancelEntry{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
canceled: false,
|
||||
refs: 1,
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -73,6 +97,86 @@ func clearDownloadCancel(itemID string) {
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
delete(cancelMap, itemID)
|
||||
if entry, ok := cancelMap[itemID]; ok {
|
||||
entry.refs--
|
||||
if entry.refs <= 0 {
|
||||
delete(cancelMap, itemID)
|
||||
}
|
||||
}
|
||||
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,6 +19,8 @@ var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||
|
||||
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
|
||||
|
||||
var qobuzSizeRegex = regexp.MustCompile(`_\d+\.jpg$`)
|
||||
|
||||
func convertSmallToMedium(imageURL string) string {
|
||||
if strings.Contains(imageURL, spotifySize300) {
|
||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||
@@ -135,7 +137,7 @@ func upgradeQobuzCover(coverURL string) string {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg")
|
||||
upgraded := qobuzSizeRegex.ReplaceAllString(coverURL, "_max.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Qobuz: upgraded to max resolution")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
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"); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
|
||||
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
|
||||
}
|
||||
if _, err := buildStoreExtensionDestPath(dir, " "); 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,6 +1,10 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
@@ -161,6 +165,184 @@ func TestBuildDownloadSuccessResponseNormalizesDecryptionDescriptor(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
req := reEnrichRequest{
|
||||
SpotifyID: "spotify-track-id",
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
extensionHealthDefaultTimeout = 4 * time.Second
|
||||
extensionHealthMaxBodyBytes = 64 * 1024
|
||||
)
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func CheckExtensionHealthJSON(extensionID string) (string, error) {
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
result := CheckExtensionHealth(ext)
|
||||
bytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
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 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 {
|
||||
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 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":
|
||||
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)
|
||||
if statusCode, ok := healthNumber(rawStatus); ok {
|
||||
if statusCode >= 200 && statusCode < 300 {
|
||||
return "online", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
|
||||
return "degraded", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
if statusCode == http.StatusInternalServerError && hasOK && okValue {
|
||||
return "online", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
return "offline", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
|
||||
if isExtensionHealthAuthRequired(detail) {
|
||||
return "degraded", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
if hasOK {
|
||||
if okValue {
|
||||
return "online", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
return "offline", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
if !hasStatus {
|
||||
return "unknown", strings.Join(messageParts, ": "), true
|
||||
}
|
||||
|
||||
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
|
||||
switch statusString {
|
||||
case "ok", "up", "online", "healthy", "operational":
|
||||
return "online", strings.Join(messageParts, ": "), true
|
||||
case "degraded", "partial", "warning", "warn":
|
||||
return "degraded", strings.Join(messageParts, ": "), true
|
||||
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
||||
return "offline", strings.Join(messageParts, ": "), true
|
||||
default:
|
||||
return "unknown", strings.Join(messageParts, ": "), 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 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"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 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,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
@@ -156,12 +157,12 @@ func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
|
||||
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
||||
}
|
||||
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
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()
|
||||
|
||||
@@ -186,16 +187,16 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||
return nil, fmt.Errorf("invalid extension manifest: %w", err)
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
@@ -213,9 +214,9 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
||||
if versionCompare > 0 {
|
||||
return m.UpgradeExtension(filePath)
|
||||
} 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +224,7 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
||||
defer m.mu.Unlock()
|
||||
|
||||
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)
|
||||
@@ -342,23 +343,90 @@ func initializeVMLocked(ext *loadedExtension) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
|
||||
vm := goja.New()
|
||||
|
||||
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
||||
jsCode, err := os.ReadFile(indexPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
|
||||
}
|
||||
|
||||
runtime := &extensionRuntime{
|
||||
extensionID: ext.ID,
|
||||
manifest: ext.Manifest,
|
||||
settings: make(map[string]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.RunString(string(jsCode)); 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()
|
||||
defer ext.VMMu.Unlock()
|
||||
return initializeVMLocked(ext)
|
||||
}
|
||||
|
||||
func initializeExtensionWithSettingsLocked(
|
||||
ext *loadedExtension,
|
||||
func initializeExtensionRuntimeWithSettings(
|
||||
vm *goja.Runtime,
|
||||
extensionID string,
|
||||
settings map[string]interface{},
|
||||
) error {
|
||||
if ext.VM == nil {
|
||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||
}
|
||||
|
||||
settingsJSON, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to save settings")
|
||||
return fmt.Errorf("failed to save settings")
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -376,11 +444,9 @@ func initializeExtensionWithSettingsLocked(
|
||||
})()
|
||||
`, string(settingsJSON))
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
result, err := vm.RunString(script)
|
||||
if err != nil {
|
||||
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err)
|
||||
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -392,14 +458,29 @@ func initializeExtensionWithSettingsLocked(
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
ext.Error = errMsg
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg)
|
||||
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, 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
|
||||
GoLog("[Extension] Initialized %s\n", ext.ID)
|
||||
return nil
|
||||
@@ -407,45 +488,56 @@ func initializeExtensionWithSettingsLocked(
|
||||
|
||||
func runCleanupLocked(ext *loadedExtension) error {
|
||||
if ext.VM != 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 {
|
||||
if err := runCleanupOnVM(ext.VM); 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
|
||||
if ext.VM.Get("extension") != nil {
|
||||
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCleanupOnVM(vm *goja.Runtime) error {
|
||||
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 {
|
||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
||||
@@ -478,7 +570,7 @@ func (m *extensionManager) UnloadExtension(extensionID string) error {
|
||||
|
||||
ext, exists := m.extensions[extensionID]
|
||||
if !exists {
|
||||
return fmt.Errorf("Extension not found")
|
||||
return fmt.Errorf("extension not found")
|
||||
}
|
||||
|
||||
ext.VMMu.Lock()
|
||||
@@ -497,7 +589,7 @@ func (m *extensionManager) GetExtension(extensionID string) (*loadedExtension, e
|
||||
|
||||
ext, exists := m.extensions[extensionID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("Extension not found")
|
||||
return nil, fmt.Errorf("extension not found")
|
||||
}
|
||||
return ext, nil
|
||||
}
|
||||
@@ -519,7 +611,7 @@ func (m *extensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
||||
|
||||
ext, exists := m.extensions[extensionID]
|
||||
if !exists {
|
||||
return fmt.Errorf("Extension not found")
|
||||
return fmt.Errorf("extension not found")
|
||||
}
|
||||
|
||||
if enabled {
|
||||
@@ -597,12 +689,12 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx
|
||||
|
||||
manifest, err := ParseManifest(manifestData)
|
||||
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")
|
||||
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 {
|
||||
@@ -665,12 +757,12 @@ func (m *extensionManager) RemoveExtension(extensionID string) error {
|
||||
// Only allows upgrades (new version > current version), not downgrades
|
||||
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
||||
}
|
||||
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
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()
|
||||
|
||||
@@ -695,16 +787,16 @@ func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension,
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||
return nil, fmt.Errorf("invalid extension manifest: %w", err)
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
@@ -712,15 +804,15 @@ func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension,
|
||||
m.mu.RUnlock()
|
||||
|
||||
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)
|
||||
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 {
|
||||
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)
|
||||
@@ -814,13 +906,13 @@ type ExtensionUpgradeInfo struct {
|
||||
|
||||
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
||||
}
|
||||
|
||||
zipReader, err := zip.OpenReader(filePath)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Cannot open extension file")
|
||||
return nil, fmt.Errorf("cannot open extension file")
|
||||
}
|
||||
defer zipReader.Close()
|
||||
|
||||
@@ -847,7 +939,7 @@ func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
||||
|
||||
newManifest, err := ParseManifest(manifestData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Invalid manifest: %w", err)
|
||||
return nil, fmt.Errorf("invalid manifest: %w", err)
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
@@ -893,7 +985,6 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
IconPath string `json:"icon_path,omitempty"`
|
||||
@@ -909,9 +1000,11 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
HasLyricsProvider bool `json:"has_lyrics_provider"`
|
||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment"`
|
||||
SkipLyrics bool `json:"skip_lyrics"`
|
||||
StopProviderFallback bool `json:"stop_provider_fallback"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"search_behavior,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"track_matching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"post_processing,omitempty"`
|
||||
ServiceHealth []ExtensionHealthCheck `json:"service_health,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
@@ -951,7 +1044,6 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
Name: ext.Manifest.Name,
|
||||
DisplayName: ext.Manifest.DisplayName,
|
||||
Version: ext.Manifest.Version,
|
||||
Author: ext.Manifest.Author,
|
||||
Description: ext.Manifest.Description,
|
||||
Homepage: ext.Manifest.Homepage,
|
||||
IconPath: iconPath,
|
||||
@@ -967,9 +1059,11 @@ func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
HasLyricsProvider: ext.Manifest.IsLyricsProvider(),
|
||||
SkipMetadataEnrichment: ext.Manifest.SkipMetadataEnrichment,
|
||||
SkipLyrics: ext.Manifest.SkipLyrics,
|
||||
StopProviderFallback: ext.Manifest.StopsProviderFallback(),
|
||||
SearchBehavior: ext.Manifest.SearchBehavior,
|
||||
TrackMatching: ext.Manifest.TrackMatching,
|
||||
PostProcessing: ext.Manifest.PostProcessing,
|
||||
ServiceHealth: ext.Manifest.ServiceHealth,
|
||||
Capabilities: ext.Manifest.Capabilities,
|
||||
}
|
||||
}
|
||||
@@ -988,7 +1082,7 @@ func (m *extensionManager) InitializeExtension(extensionID string, settings map[
|
||||
|
||||
ext, exists := m.extensions[extensionID]
|
||||
if !exists {
|
||||
return fmt.Errorf("Extension not found")
|
||||
return fmt.Errorf("extension not found")
|
||||
}
|
||||
|
||||
ext.VMMu.Lock()
|
||||
@@ -1006,7 +1100,7 @@ func (m *extensionManager) CleanupExtension(extensionID string) error {
|
||||
|
||||
ext, exists := m.extensions[extensionID]
|
||||
if !exists {
|
||||
return fmt.Errorf("Extension not found")
|
||||
return fmt.Errorf("extension not found")
|
||||
}
|
||||
|
||||
if ext.VM == nil {
|
||||
@@ -1055,15 +1149,29 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
|
||||
}
|
||||
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.
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||
try {
|
||||
var result = extension.%s();
|
||||
if (result && typeof result.then === 'function') {
|
||||
// Handle promise - return pending status
|
||||
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 };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,10 @@ const (
|
||||
)
|
||||
|
||||
type ExtensionPermissions struct {
|
||||
Network []string `json:"network"`
|
||||
Storage bool `json:"storage"`
|
||||
File bool `json:"file"`
|
||||
Network []string `json:"network"`
|
||||
Storage bool `json:"storage"`
|
||||
File bool `json:"file"`
|
||||
AllowHTTP bool `json:"allowHttp,omitempty"`
|
||||
}
|
||||
|
||||
type ExtensionSetting struct {
|
||||
@@ -101,11 +102,21 @@ type PostProcessingConfig struct {
|
||||
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 ExtensionManifest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
@@ -116,11 +127,13 @@ type ExtensionManifest struct {
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
||||
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
@@ -155,10 +168,6 @@ func (m *ExtensionManifest) Validate() error {
|
||||
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) == "" {
|
||||
return &ManifestValidationError{Field: "description", Message: "description is required"}
|
||||
}
|
||||
@@ -207,6 +216,28 @@ 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -231,6 +262,13 @@ func (m *ExtensionManifest) IsLyricsProvider() bool {
|
||||
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 {
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
for _, allowed := range m.Permissions.Network {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
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,33 +1,38 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
||||
func TestSetMetadataProviderPriorityStripsRetiredBuiltIns(t *testing.T) {
|
||||
original := GetMetadataProviderPriority()
|
||||
defer SetMetadataProviderPriority(original)
|
||||
|
||||
SetMetadataProviderPriority([]string{"tidal"})
|
||||
SetMetadataProviderPriority([]string{"qobuz"})
|
||||
got := GetMetadataProviderPriority()
|
||||
want := []string{"tidal", "deezer", "qobuz"}
|
||||
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)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected retired built-in qobuz to be stripped, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetExtensionFallbackProviderIDsSkipsBuiltInsAndDuplicates(t *testing.T) {
|
||||
func TestSetExtensionFallbackProviderIDsDedupesExtensions(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
defer SetExtensionFallbackProviderIDs(original)
|
||||
|
||||
SetExtensionFallbackProviderIDs([]string{"ext-a", "tidal", "ext-a", " ext-b "})
|
||||
SetExtensionFallbackProviderIDs([]string{"ext-a", "ext-a", " ext-b "})
|
||||
|
||||
got := GetExtensionFallbackProviderIDs()
|
||||
want := []string{"ext-a", "ext-b"}
|
||||
@@ -50,9 +55,6 @@ func TestIsExtensionFallbackAllowedDefaultsToAllExtensions(t *testing.T) {
|
||||
if !isExtensionFallbackAllowed("custom-ext") {
|
||||
t.Fatal("expected custom extension to be allowed when no fallback allowlist is configured")
|
||||
}
|
||||
if !isExtensionFallbackAllowed("qobuz") {
|
||||
t.Fatal("expected built-in provider to remain allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExtensionFallbackAllowedRespectsAllowlist(t *testing.T) {
|
||||
@@ -79,7 +81,7 @@ func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
|
||||
SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"})
|
||||
|
||||
got := GetProviderPriority()
|
||||
want := []string{"qobuz", "custom-ext", "tidal"}
|
||||
want := []string{"custom-ext"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||
}
|
||||
@@ -123,6 +125,110 @@ func TestNormalizeDownloadDecryptionInfoCanonicalizesMovAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -180,11 +286,72 @@ func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -195,55 +362,268 @@ func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
|
||||
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 TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
||||
func TestSearchTracksWithMetadataProvidersIgnoresRetiredBuiltIns(t *testing.T) {
|
||||
originalPriority := GetMetadataProviderPriority()
|
||||
originalSearch := searchBuiltInMetadataTracksFunc
|
||||
defer func() {
|
||||
SetMetadataProviderPriority(originalPriority)
|
||||
searchBuiltInMetadataTracksFunc = originalSearch
|
||||
}()
|
||||
|
||||
SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
SetMetadataProviderPriority([]string{"qobuz"})
|
||||
|
||||
manager := getExtensionManager()
|
||||
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
||||
}
|
||||
if len(tracks) != 3 {
|
||||
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)
|
||||
}
|
||||
if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" {
|
||||
t.Fatalf("unexpected provider call order: %v", calls)
|
||||
if len(tracks) != 0 {
|
||||
t.Fatalf("expected no tracks from retired built-in provider, got %+v", tracks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtensionSearchResultAcceptsObjectAndArrayShapes(t *testing.T) {
|
||||
vm := goja.New()
|
||||
value, err := vm.RunString(`({
|
||||
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,6 +5,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -93,6 +94,9 @@ type extensionRuntime struct {
|
||||
activeDownloadMu sync.RWMutex
|
||||
activeDownloadItemID string
|
||||
|
||||
activeRequestMu sync.RWMutex
|
||||
activeRequestID string
|
||||
|
||||
storageMu sync.RWMutex
|
||||
storageCache map[string]interface{}
|
||||
storageLoaded bool
|
||||
@@ -136,12 +140,60 @@ func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
|
||||
storageFlushDelay: defaultStorageFlushDelay,
|
||||
}
|
||||
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second), true)
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout, false)
|
||||
|
||||
return runtime
|
||||
}
|
||||
|
||||
func extensionHTTPTimeout(ext *loadedExtension, fallback time.Duration) time.Duration {
|
||||
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()
|
||||
defer r.activeDownloadMu.Unlock()
|
||||
@@ -160,18 +212,59 @@ func (r *extensionRuntime) getActiveDownloadItemID() string {
|
||||
return r.activeDownloadItemID
|
||||
}
|
||||
|
||||
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
||||
func (r *extensionRuntime) setActiveRequestID(requestID string) {
|
||||
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
|
||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
||||
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
||||
// API calls can use response compression for faster metadata/search loads,
|
||||
// while media downloads keep identity transfer semantics for progress/streaming.
|
||||
transport := sharedTransport
|
||||
if compressResponses {
|
||||
transport = extensionAPITransport
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Transport: transport,
|
||||
Timeout: timeout,
|
||||
Jar: jar,
|
||||
}
|
||||
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)
|
||||
return fmt.Errorf("redirect blocked: only https is allowed")
|
||||
}
|
||||
@@ -413,6 +506,12 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
|
||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||
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)
|
||||
|
||||
logObj := vm.NewObject()
|
||||
|
||||
@@ -458,9 +458,10 @@ func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
//lint:ignore SA1019 Blowfish is required for legacy extension crypto compatibility.
|
||||
"golang.org/x/crypto/blowfish"
|
||||
)
|
||||
|
||||
|
||||
@@ -131,6 +131,7 @@ func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
"sample_rate": quality.SampleRate,
|
||||
"total_samples": quality.TotalSamples,
|
||||
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
|
||||
"codec": quality.Codec,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
@@ -134,6 +135,9 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
|
||||
var onProgress goja.Callable
|
||||
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]) {
|
||||
optionsObj := call.Arguments[2].Export()
|
||||
if opts, ok := optionsObj.(map[string]interface{}); ok {
|
||||
@@ -148,9 +152,39 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
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)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -159,6 +193,20 @@ 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)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -166,17 +214,13 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
}
|
||||
|
||||
client := r.downloadClient
|
||||
if client == nil {
|
||||
client = r.httpClient
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
@@ -188,7 +232,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("HTTP error: %d", resp.StatusCode),
|
||||
@@ -204,14 +248,19 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
contentLength := resp.ContentLength
|
||||
activeItemID := r.getActiveDownloadItemID()
|
||||
if activeItemID != "" && contentLength > 0 {
|
||||
if activeItemID != "" {
|
||||
SetItemDownloading(activeItemID)
|
||||
}
|
||||
|
||||
contentLength := resp.ContentLength
|
||||
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
|
||||
if shouldTrackItemBytes && contentLength > 0 {
|
||||
SetItemBytesTotal(activeItemID, contentLength)
|
||||
}
|
||||
|
||||
var progressWriter interface{ Write([]byte) (int, error) } = out
|
||||
if activeItemID != "" {
|
||||
if shouldTrackItemBytes {
|
||||
progressWriter = NewItemProgressWriter(out, activeItemID)
|
||||
}
|
||||
|
||||
@@ -262,6 +311,14 @@ 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)
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -271,6 +328,239 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
// fileDownloadChunked downloads a URL using sequential Range requests.
|
||||
// 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 != "" {
|
||||
// Format: "bytes 0-1/12345"
|
||||
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
|
||||
}
|
||||
|
||||
// Non-success status
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
// Read chunk body and write to file
|
||||
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 {
|
||||
return r.vm.ToValue(false)
|
||||
|
||||
@@ -17,6 +17,24 @@ type HTTPResponse struct {
|
||||
Headers map[string]string `json:"headers"`
|
||||
}
|
||||
|
||||
const maxExtensionHTTPResponseBytes = 16 << 20
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -26,7 +44,8 @@ func (r *extensionRuntime) validateDomain(urlStr string) error {
|
||||
if parsed.Scheme == "" {
|
||||
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")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
@@ -81,6 +100,7 @@ func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -98,7 +118,7 @@ func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
@@ -175,6 +195,7 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -195,7 +216,7 @@ func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
@@ -284,6 +305,7 @@ func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -304,7 +326,7 @@ func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
@@ -410,6 +432,7 @@ func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -429,7 +452,7 @@ func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
body, err := readExtensionHTTPResponseBody(resp)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
|
||||
@@ -69,12 +69,13 @@ func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
}
|
||||
if bodyStr != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -340,16 +340,6 @@ func (r *extensionRuntime) ensureCredentialsLoaded() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -249,6 +249,96 @@ func (r *extensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(getRandomUserAgent())
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) appVersion(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)
|
||||
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||
@@ -324,6 +414,83 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
"bitDepth": quality.BitDepth,
|
||||
"sampleRate": quality.SampleRate,
|
||||
"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,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ type storeExtension struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
@@ -83,7 +82,6 @@ type storeExtensionResponse struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
@@ -103,7 +101,6 @@ func (e *storeExtension) toResponse() storeExtensionResponse {
|
||||
Name: e.Name,
|
||||
DisplayName: e.getDisplayName(),
|
||||
Version: e.Version,
|
||||
Author: e.Author,
|
||||
Description: e.Description,
|
||||
DownloadURL: e.getDownloadURL(),
|
||||
IconURL: e.getIconURL(),
|
||||
@@ -253,7 +250,17 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
|
||||
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
||||
|
||||
client := NewHTTPClientWithTimeout(30 * time.Second)
|
||||
resp, err := client.Get(s.registryURL)
|
||||
req, err := http.NewRequest(http.MethodGet, s.registryURL, nil)
|
||||
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 s.cache != nil {
|
||||
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
||||
@@ -348,7 +355,13 @@ func (s *extensionStore) downloadExtension(extensionID string, destPath string)
|
||||
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
||||
|
||||
client := NewHTTPClientWithTimeout(5 * time.Minute)
|
||||
resp, err := client.Get(ext.getDownloadURL())
|
||||
req, err := http.NewRequest(http.MethodGet, ext.getDownloadURL(), nil)
|
||||
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 {
|
||||
return fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
@@ -481,8 +494,7 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor
|
||||
if query != "" {
|
||||
if !containsIgnoreCase(ext.Name, queryLower) &&
|
||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||
!containsIgnoreCase(ext.Description, queryLower) &&
|
||||
!containsIgnoreCase(ext.Author, queryLower) {
|
||||
!containsIgnoreCase(ext.Description, queryLower) {
|
||||
found := false
|
||||
for _, tag := range ext.Tags {
|
||||
if containsIgnoreCase(tag, queryLower) {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
@@ -12,7 +16,6 @@ func TestParseManifest_Valid(t *testing.T) {
|
||||
"name": "test-provider",
|
||||
"displayName": "Test Provider",
|
||||
"version": "1.0.0",
|
||||
"author": "Test Author",
|
||||
"description": "A test extension",
|
||||
"type": ["metadata_provider"],
|
||||
"permissions": {
|
||||
@@ -43,10 +46,26 @@ 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) {
|
||||
invalidManifest := `{
|
||||
"version": "1.0.0",
|
||||
"author": "Test Author",
|
||||
"description": "A test extension",
|
||||
"type": ["metadata_provider"]
|
||||
}`
|
||||
@@ -61,7 +80,6 @@ func TestParseManifest_MissingType(t *testing.T) {
|
||||
invalidManifest := `{
|
||||
"name": "test-provider",
|
||||
"version": "1.0.0",
|
||||
"author": "Test Author",
|
||||
"description": "A test extension"
|
||||
}`
|
||||
|
||||
@@ -98,7 +116,6 @@ func TestIsDomainAllowed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
// Create a mock extension with limited network permissions
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
@@ -127,6 +144,15 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
if err := runtime.validateDomain("https://notallowed.com/path"); err == nil {
|
||||
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) {
|
||||
@@ -235,14 +261,176 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("stringifyJSON failed: %v", err)
|
||||
}
|
||||
// JSON output may vary in order, just check it's valid
|
||||
if result.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) {
|
||||
// Create extension with limited network permissions
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
|
||||
@@ -20,6 +20,10 @@ func (e *JSExecutionError) Error() string {
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, fmt.Errorf("extension runtime unavailable")
|
||||
}
|
||||
@@ -28,7 +32,10 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
timeout = DefaultJSTimeout
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
type result struct {
|
||||
@@ -67,11 +74,16 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
case res := <-resultCh:
|
||||
return res.value, res.err
|
||||
case <-ctx.Done():
|
||||
cancelled := ctx.Err() == context.Canceled
|
||||
interruptMu.Lock()
|
||||
interrupted = true
|
||||
interruptMu.Unlock()
|
||||
|
||||
vm.Interrupt("execution timeout")
|
||||
if cancelled {
|
||||
vm.Interrupt("extension request cancelled")
|
||||
} else {
|
||||
vm.Interrupt("execution timeout")
|
||||
}
|
||||
|
||||
// MUST wait for the goroutine to finish before returning.
|
||||
// The Goja VM is NOT thread-safe — if we return while the goroutine
|
||||
@@ -80,6 +92,9 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
// pointer dereference.
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
if cancelled {
|
||||
return nil, ErrExtensionRequestCancelled
|
||||
}
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
@@ -91,6 +106,9 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
|
||||
// Log a warning — the VM should NOT be reused after this.
|
||||
GoLog("[extensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
|
||||
if cancelled {
|
||||
return nil, ErrExtensionRequestCancelled
|
||||
}
|
||||
return nil, &JSExecutionError{
|
||||
Message: "execution timeout exceeded (force)",
|
||||
IsTimeout: true,
|
||||
@@ -102,7 +120,11 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
// 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
|
||||
func RunWithTimeoutAndRecover(vm *goja.Runtime, script string, timeout time.Duration) (goja.Value, error) {
|
||||
result, err := RunWithTimeout(vm, script, timeout)
|
||||
return RunWithTimeoutContextAndRecover(context.Background(), 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 {
|
||||
vm.ClearInterrupt()
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -17,24 +19,66 @@ var (
|
||||
)
|
||||
|
||||
func sanitizeFilename(filename string) string {
|
||||
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
||||
sanitized := strings.ReplaceAll(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.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 = strings.ToValidUTF8(sanitized, "_")
|
||||
}
|
||||
|
||||
if len(sanitized) > 200 {
|
||||
sanitized = sanitized[:200]
|
||||
sanitized = truncateUTF8Bytes(sanitized, 200)
|
||||
sanitized = strings.TrimSpace(strings.Trim(sanitized, ". "))
|
||||
sanitized = strings.Trim(sanitized, "_ ")
|
||||
}
|
||||
|
||||
if sanitized == "" {
|
||||
sanitized = "untitled"
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
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 {
|
||||
if template == "" {
|
||||
template = "{artist} - {title}"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestBuildFilenameFromTemplate_WithRawTrackAndDisc(t *testing.T) {
|
||||
metadata := map[string]interface{}{
|
||||
@@ -83,3 +87,28 @@ func TestBuildFilenameFromTemplate_DateStrftimeFormattingWithYearOnly(t *testing
|
||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFilenameMatchesDesktopSpacingBehavior(t *testing.T) {
|
||||
got := sanitizeFilename(` "Text In Quotes"?%* / Demo `)
|
||||
want := "Text In Quotes % Demo"
|
||||
if got != want {
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFilenameFallsBackToUnknownWhenEmpty(t *testing.T) {
|
||||
got := sanitizeFilename(`<>:"/\|?*`)
|
||||
if got != "Unknown" {
|
||||
t.Fatalf("expected %q, got %q", "Unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFilenameTruncatesWithoutSplittingUTF8(t *testing.T) {
|
||||
got := sanitizeFilename(strings.Repeat("あ", 80))
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatalf("sanitizeFilename returned invalid UTF-8: %q", got)
|
||||
}
|
||||
if len(got) > 200 {
|
||||
t.Fatalf("sanitizeFilename length = %d, want <= 200", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ module github.com/zarz/spotiflac_android/go_backend
|
||||
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.25.8
|
||||
toolchain go1.25.9
|
||||
|
||||
require (
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
||||
@@ -10,9 +10,10 @@ require (
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||
github.com/go-flac/go-flac/v2 v2.0.4
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/text v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -21,9 +22,8 @@ require (
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
)
|
||||
|
||||
@@ -30,22 +30,22 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60 h1:MOzyaj0wu2xneBkzkg9LHNYjDBB4W5vP043A2SYQRPA=
|
||||
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60/go.mod h1:th6VJvzjMbrYF8SduQY5rpD0HG0GleGxjadkqSxFs3k=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b h1:Qt2eaXcZ8x20iAcoZ6AceeMMtnjuPHvC51KRCH1DKSQ=
|
||||
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b/go.mod h1:5Fu78lew5ucMXt8w2KYcwvxu2rkC/liHzUvaoiI+H/M=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -16,6 +16,19 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func userAgentForURL(u *url.URL) string {
|
||||
if u == nil {
|
||||
return getRandomUserAgent()
|
||||
}
|
||||
|
||||
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
|
||||
if host == "api.zarz.moe" {
|
||||
return appUserAgent()
|
||||
}
|
||||
|
||||
return getRandomUserAgent()
|
||||
}
|
||||
|
||||
func getRandomUserAgent() string {
|
||||
chromeVersion := rand.Intn(26) + 120
|
||||
chromeBuild := rand.Intn(1500) + 6000
|
||||
@@ -66,6 +79,24 @@ var sharedTransport = &http.Transport{
|
||||
DisableCompression: true,
|
||||
}
|
||||
|
||||
var extensionAPITransport = &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
MaxConnsPerHost: 20,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
DisableKeepAlives: false,
|
||||
ForceAttemptHTTP2: true,
|
||||
WriteBufferSize: 64 * 1024,
|
||||
ReadBufferSize: 64 * 1024,
|
||||
DisableCompression: false,
|
||||
}
|
||||
|
||||
var metadataTransport = &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
@@ -118,6 +149,7 @@ func GetDownloadClient() *http.Client {
|
||||
|
||||
func CloseIdleConnections() {
|
||||
sharedTransport.CloseIdleConnections()
|
||||
extensionAPITransport.CloseIdleConnections()
|
||||
metadataTransport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
@@ -130,6 +162,7 @@ func SetNetworkCompatibilityOptions(allowHTTP, insecureTLS bool) {
|
||||
networkCompatibilityMu.Unlock()
|
||||
|
||||
applyTLSCompatibility(sharedTransport, insecureTLS)
|
||||
applyTLSCompatibility(extensionAPITransport, insecureTLS)
|
||||
applyTLSCompatibility(metadataTransport, insecureTLS)
|
||||
CloseIdleConnections()
|
||||
|
||||
@@ -225,7 +258,7 @@ func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request
|
||||
}
|
||||
|
||||
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", userAgentForURL(req.URL))
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||
@@ -255,7 +288,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
|
||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
|
||||
|
||||
resp, err := client.Do(reqCopy)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,7 +11,7 @@ func GetCloudflareBypassClient() *http.Client {
|
||||
}
|
||||
|
||||
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", userAgentForURL(req.URL))
|
||||
resp, err := sharedClient.Do(req)
|
||||
if err != nil {
|
||||
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHTTPUtilityHelpers(t *testing.T) {
|
||||
SetAppVersion("7.0.0")
|
||||
apiURL := mustParseURL(t, "https://api.zarz.moe/test")
|
||||
if ua := userAgentForURL(apiURL); !strings.Contains(ua, "7.0.0") {
|
||||
t.Fatalf("api user agent = %q", ua)
|
||||
}
|
||||
if userAgentForURL(nil) == "" || userAgentForURL(mustParseURL(t, "https://example.com")) == "" {
|
||||
t.Fatal("expected fallback user agent")
|
||||
}
|
||||
if NewHTTPClientWithTimeout(time.Second).Timeout != time.Second || NewMetadataHTTPClient(time.Second).Timeout != time.Second {
|
||||
t.Fatal("client timeout mismatch")
|
||||
}
|
||||
if GetSharedClient() == nil || GetDownloadClient() == nil {
|
||||
t.Fatal("expected shared clients")
|
||||
}
|
||||
SetNetworkCompatibilityOptions(true, true)
|
||||
if opts := GetNetworkCompatibilityOptions(); !opts.AllowHTTP || !opts.InsecureTLS {
|
||||
t.Fatalf("network opts = %#v", opts)
|
||||
}
|
||||
SetNetworkCompatibilityOptions(false, false)
|
||||
if !canFallbackToHTTP(&http.Request{Method: http.MethodGet}) {
|
||||
t.Fatal("GET should fallback")
|
||||
}
|
||||
if canFallbackToHTTP(&http.Request{Method: http.MethodPost}) {
|
||||
t.Fatal("POST without GetBody should not fallback")
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodPost, "https://example.com/path", strings.NewReader("body"))
|
||||
req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("body")), nil }
|
||||
cloned, err := cloneRequestWithHTTPScheme(req, "http")
|
||||
if err != nil || cloned.URL.Scheme != "http" || cloned.Body == nil {
|
||||
t.Fatalf("cloneRequestWithHTTPScheme = %#v/%v", cloned, err)
|
||||
}
|
||||
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Header.Get("User-Agent") == "" {
|
||||
t.Fatal("missing User-Agent")
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("ok")), Request: req}, nil
|
||||
})}
|
||||
resp, err := DoRequestWithUserAgent(client, mustNewRequest(t, "https://example.com/ok"))
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
t.Fatalf("DoRequestWithUserAgent = %#v/%v", resp, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
attempts := 0
|
||||
retryClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
attempts++
|
||||
switch attempts {
|
||||
case 1:
|
||||
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("server")), Request: req}, nil
|
||||
case 2:
|
||||
return &http.Response{StatusCode: 429, Header: http.Header{"Retry-After": []string{"0"}}, Body: io.NopCloser(strings.NewReader("rate")), Request: req}, nil
|
||||
default:
|
||||
return &http.Response{StatusCode: 204, Body: io.NopCloser(strings.NewReader("")), Request: req}, nil
|
||||
}
|
||||
})}
|
||||
resp, err = DoRequestWithRetry(retryClient, mustNewRequest(t, "https://example.com/retry"), RetryConfig{MaxRetries: 3, InitialDelay: 0, MaxDelay: time.Millisecond, BackoffFactor: 2})
|
||||
if err != nil || resp.StatusCode != 204 || attempts != 3 {
|
||||
t.Fatalf("DoRequestWithRetry = %#v/%v attempts=%d", resp, err, attempts)
|
||||
}
|
||||
resp.Body.Close()
|
||||
blockingClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 403, Body: io.NopCloser(strings.NewReader("access denied by region")), Request: req}, nil
|
||||
})}
|
||||
if _, err := DoRequestWithRetry(blockingClient, mustNewRequest(t, "https://blocked.example.com"), RetryConfig{MaxRetries: 0}); err == nil {
|
||||
t.Fatal("expected blocking retry error")
|
||||
}
|
||||
|
||||
if _, err := ReadResponseBody(nil); err == nil {
|
||||
t.Fatal("expected nil response body error")
|
||||
}
|
||||
if _, err := ReadResponseBody(&http.Response{Body: io.NopCloser(strings.NewReader(""))}); err == nil {
|
||||
t.Fatal("expected empty response body error")
|
||||
}
|
||||
if body, err := ReadResponseBody(&http.Response{Body: io.NopCloser(strings.NewReader("ok"))}); err != nil || string(body) != "ok" {
|
||||
t.Fatalf("ReadResponseBody = %q/%v", body, err)
|
||||
}
|
||||
if err := ValidateResponse(nil); err == nil {
|
||||
t.Fatal("expected nil response validation error")
|
||||
}
|
||||
if err := ValidateResponse(&http.Response{StatusCode: 404, Status: "404 Not Found"}); err == nil {
|
||||
t.Fatal("expected bad status validation error")
|
||||
}
|
||||
if err := ValidateResponse(&http.Response{StatusCode: 200}); err != nil {
|
||||
t.Fatalf("ValidateResponse: %v", err)
|
||||
}
|
||||
if msg := BuildErrorMessage("api", 500, strings.Repeat("x", 120)); !strings.Contains(msg, "...") {
|
||||
t.Fatalf("BuildErrorMessage = %q", msg)
|
||||
}
|
||||
if calculateNextDelay(10*time.Millisecond, RetryConfig{BackoffFactor: 3, MaxDelay: 20 * time.Millisecond}) != 20*time.Millisecond {
|
||||
t.Fatal("calculateNextDelay mismatch")
|
||||
}
|
||||
if getRetryAfterDuration(&http.Response{Header: http.Header{"Retry-After": []string{"bad"}}}) != 0 {
|
||||
t.Fatal("invalid retry-after should be zero")
|
||||
}
|
||||
if isp := IsISPBlocking(errors.New("connection reset by peer"), "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") {
|
||||
t.Fatalf("IsISPBlocking = %#v", isp)
|
||||
}
|
||||
if !CheckAndLogISPBlocking(errors.New("i/o timeout"), "https://timeout.example/x", "test") {
|
||||
t.Fatal("expected logged ISP blocking")
|
||||
}
|
||||
if wrapped := WrapErrorWithISPCheck(errors.New("connection refused"), "https://refused.example/x", "test"); wrapped == nil || !strings.Contains(wrapped.Error(), "ISP blocking") {
|
||||
t.Fatalf("WrapErrorWithISPCheck = %v", wrapped)
|
||||
}
|
||||
if WrapErrorWithISPCheck(nil, "", "test") != nil {
|
||||
t.Fatal("nil wrap should stay nil")
|
||||
}
|
||||
if extractDomain("https://example.com/path") != "example.com" || extractDomain("bad://") != "unknown" || extractDomain("") != "unknown" {
|
||||
t.Fatal("extractDomain mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterHelpers(t *testing.T) {
|
||||
limiter := NewRateLimiter(1, time.Hour)
|
||||
if limiter.Available() != 1 {
|
||||
t.Fatalf("available = %d", limiter.Available())
|
||||
}
|
||||
if !limiter.TryAcquire() || limiter.TryAcquire() {
|
||||
t.Fatal("TryAcquire mismatch")
|
||||
}
|
||||
if limiter.Available() != 0 {
|
||||
t.Fatalf("available after acquire = %d", limiter.Available())
|
||||
}
|
||||
if GetSongLinkRateLimiter() == nil {
|
||||
t.Fatal("expected global limiter")
|
||||
}
|
||||
}
|
||||
|
||||
func mustNewRequest(t *testing.T, rawURL string) *http.Request {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func mustParseURL(t *testing.T, rawURL string) *url.URL {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -10,16 +10,13 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
utls "github.com/refraction-networking/utls"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
type utlsTransport struct {
|
||||
dialer *net.Dialer
|
||||
mu sync.Mutex
|
||||
h2Transports map[string]*http2.Transport
|
||||
dialer *net.Dialer
|
||||
}
|
||||
|
||||
func newUTLSTransport() *utlsTransport {
|
||||
@@ -28,7 +25,6 @@ func newUTLSTransport() *utlsTransport {
|
||||
Timeout: 30 * Second,
|
||||
KeepAlive: 30 * Second,
|
||||
},
|
||||
h2Transports: make(map[string]*http2.Transport),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +97,7 @@ func GetCloudflareBypassClient() *http.Client {
|
||||
}
|
||||
|
||||
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", userAgentForURL(req.URL))
|
||||
|
||||
resp, err := sharedClient.Do(req)
|
||||
if err == nil {
|
||||
@@ -129,7 +125,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
|
||||
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
|
||||
|
||||
return cloudflareBypassClient.Do(reqCopy)
|
||||
}
|
||||
@@ -155,7 +151,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
||||
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
|
||||
|
||||
return cloudflareBypassClient.Do(reqCopy)
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ var (
|
||||
var supportedAudioFormats = map[string]bool{
|
||||
".flac": true,
|
||||
".m4a": true,
|
||||
".mp4": true,
|
||||
".aac": true,
|
||||
".mp3": true,
|
||||
".opus": true,
|
||||
".ogg": true,
|
||||
@@ -87,10 +89,23 @@ type scannedCueFileInfo struct {
|
||||
audioPath string
|
||||
}
|
||||
|
||||
func isLibraryStagingFile(path string) bool {
|
||||
name := strings.ToLower(filepath.Base(path))
|
||||
if strings.HasSuffix(name, ".partial") {
|
||||
return true
|
||||
}
|
||||
for ext := range supportedAudioFormats {
|
||||
if strings.HasSuffix(name, ".partial"+ext) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
|
||||
var files []libraryAudioFileInfo
|
||||
|
||||
err := filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
|
||||
err := filepath.WalkDir(folderPath, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -101,7 +116,10 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
|
||||
default:
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if isLibraryStagingFile(path) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -110,6 +128,11 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
|
||||
return nil
|
||||
}
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
files = append(files, libraryAudioFileInfo{
|
||||
path: path,
|
||||
modTime: info.ModTime().UnixMilli(),
|
||||
@@ -271,18 +294,10 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
|
||||
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, 0)
|
||||
}
|
||||
|
||||
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, "", "", scanTime, knownModTime)
|
||||
}
|
||||
|
||||
func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, "", scanTime, knownModTime)
|
||||
}
|
||||
|
||||
func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
|
||||
ext := resolveLibraryAudioExt(filePath, displayNameHint)
|
||||
|
||||
@@ -317,7 +332,7 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
|
||||
switch ext {
|
||||
case ".flac":
|
||||
return scanFLACFile(filePath, result, displayNameHint)
|
||||
case ".m4a":
|
||||
case ".m4a", ".mp4", ".aac":
|
||||
return scanM4AFile(filePath, result, displayNameHint)
|
||||
case ".mp3":
|
||||
return scanMP3File(filePath, result, displayNameHint)
|
||||
@@ -397,7 +412,6 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
metadata, err := ReadM4ATags(filePath)
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
|
||||
if metadata != nil {
|
||||
@@ -424,12 +438,54 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
result.Duration = quality.Duration
|
||||
if quality.Bitrate > 0 {
|
||||
result.Bitrate = quality.Bitrate
|
||||
}
|
||||
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
|
||||
result.Format = format
|
||||
if isLosslessLibraryFormat(format) {
|
||||
result.Bitrate = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if metadata == nil {
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func libraryFormatForM4ACodec(codec string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(codec)) {
|
||||
case "flac":
|
||||
return "flac"
|
||||
case "alac":
|
||||
return "alac"
|
||||
case "eac3", "ec-3":
|
||||
return "eac3"
|
||||
case "ac3", "ac-3":
|
||||
return "ac3"
|
||||
case "ac4", "ac-4":
|
||||
return "ac4"
|
||||
case "aac", "mp4a":
|
||||
return "m4a"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isLosslessLibraryFormat(format string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "flac", "alac":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadID3Tags(filePath)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLibraryScanFullIncrementalAndMetadataFallbacks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
albumDir := filepath.Join(dir, "Album")
|
||||
if err := os.MkdirAll(albumDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mp3Path := filepath.Join(albumDir, "Artist - Song.mp3")
|
||||
if err := os.WriteFile(mp3Path, []byte("not really mp3"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
numberedPath := filepath.Join(albumDir, "01 - Intro.ogg")
|
||||
if err := os.WriteFile(numberedPath, []byte("not really ogg"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
apePath := filepath.Join(albumDir, "tagged.ape")
|
||||
if err := os.WriteFile(apePath, []byte("audio"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := WriteAPETags(apePath, &APETag{Items: AudioMetadataToAPEItems(&AudioMetadata{
|
||||
Title: "Tagged",
|
||||
Artist: "APE Artist",
|
||||
Album: "APE Album",
|
||||
TrackNumber: 2,
|
||||
TotalTracks: 3,
|
||||
Date: "2026",
|
||||
Genre: "Pop",
|
||||
Composer: "Composer",
|
||||
})}); err != nil {
|
||||
t.Fatalf("write ape tags: %v", err)
|
||||
}
|
||||
cuePath, _ := writeExportCueFixture(t, albumDir)
|
||||
if err := os.WriteFile(filepath.Join(albumDir, "ignored.txt"), []byte("ignore"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyPartialPath := filepath.Join(albumDir, "Artist - Song.partial.flac")
|
||||
if err := os.WriteFile(legacyPartialPath, []byte("partial flac"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newPartialPath := filepath.Join(albumDir, "Artist - Song.flac.partial")
|
||||
if err := os.WriteFile(newPartialPath, []byte("partial flac"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
files, err := collectLibraryAudioFiles(dir, make(chan struct{}))
|
||||
if err != nil {
|
||||
t.Fatalf("collectLibraryAudioFiles: %v", err)
|
||||
}
|
||||
if len(files) < 4 {
|
||||
t.Fatalf("files = %#v", files)
|
||||
}
|
||||
for _, file := range files {
|
||||
if file.path == legacyPartialPath || file.path == newPartialPath {
|
||||
t.Fatalf("staging file should be ignored: %#v", files)
|
||||
}
|
||||
}
|
||||
cancelCh := make(chan struct{})
|
||||
close(cancelCh)
|
||||
if _, err := collectLibraryAudioFiles(dir, cancelCh); err == nil {
|
||||
t.Fatal("expected cancelled collect")
|
||||
}
|
||||
|
||||
jsonText, err := ScanLibraryFolder(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanLibraryFolder: %v", err)
|
||||
}
|
||||
var results []LibraryScanResult
|
||||
if err := json.Unmarshal([]byte(jsonText), &results); err != nil {
|
||||
t.Fatalf("decode scan results: %v", err)
|
||||
}
|
||||
if len(results) < 4 {
|
||||
t.Fatalf("scan results = %#v", results)
|
||||
}
|
||||
foundTagged := false
|
||||
for _, result := range results {
|
||||
if result.FilePath == apePath {
|
||||
foundTagged = result.TrackName == "Tagged" && result.ArtistName == "APE Artist"
|
||||
}
|
||||
}
|
||||
if !foundTagged {
|
||||
t.Fatalf("tagged APE not found in %#v", results)
|
||||
}
|
||||
if progress := GetLibraryScanProgress(); !strings.Contains(progress, `"IsComplete":true`) && !strings.Contains(progress, `"is_complete":true`) {
|
||||
t.Fatalf("progress = %s", progress)
|
||||
}
|
||||
|
||||
metaJSON, err := ReadAudioMetadataWithDisplayName(mp3Path, "Display Artist - Display Song.mp3")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAudioMetadataWithDisplayName: %v", err)
|
||||
}
|
||||
if !strings.Contains(metaJSON, "Display Song") {
|
||||
t.Fatalf("metadata json = %s", metaJSON)
|
||||
}
|
||||
noExtPath := filepath.Join(albumDir, "noext")
|
||||
if err := os.WriteFile(noExtPath, []byte("audio"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noExtJSON, err := ReadAudioMetadataWithDisplayNameAndCoverCacheKey(noExtPath, "Artist - No Ext.mp3", "cache-key")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAudioMetadataWithDisplayNameAndCoverCacheKey: %v", err)
|
||||
}
|
||||
if !strings.Contains(noExtJSON, "No Ext") {
|
||||
t.Fatalf("no ext metadata = %s", noExtJSON)
|
||||
}
|
||||
|
||||
existing := map[string]int64{}
|
||||
for _, file := range files {
|
||||
existing[file.path] = file.modTime
|
||||
}
|
||||
if info, err := os.Stat(cuePath); err == nil {
|
||||
existing[cuePath+"#track01"] = info.ModTime().UnixMilli()
|
||||
}
|
||||
incJSON, err := scanLibraryFolderIncrementalWithExistingFiles(dir, existing)
|
||||
if err != nil {
|
||||
t.Fatalf("incremental existing: %v", err)
|
||||
}
|
||||
var inc IncrementalScanResult
|
||||
if err := json.Unmarshal([]byte(incJSON), &inc); err != nil {
|
||||
t.Fatalf("decode incremental: %v", err)
|
||||
}
|
||||
if inc.SkippedCount == 0 {
|
||||
t.Fatalf("incremental = %#v", inc)
|
||||
}
|
||||
if _, err := ScanLibraryFolderIncremental("", "{}"); err == nil {
|
||||
t.Fatal("expected empty incremental folder error")
|
||||
}
|
||||
if incJSON, err := ScanLibraryFolderIncremental(dir, `not-json`); err != nil || incJSON == "" {
|
||||
t.Fatalf("incremental invalid existing JSON = %q/%v", incJSON, err)
|
||||
}
|
||||
|
||||
snapshot := filepath.Join(dir, "snapshot.txt")
|
||||
if err := os.WriteFile(snapshot, []byte("bad\n123\t"+mp3Path+"\nnotint\tpath\n999\t"+filepath.Join(dir, "deleted.mp3")+"\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fromSnapshot, err := ScanLibraryFolderIncrementalFromSnapshot(dir, snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot incremental: %v", err)
|
||||
}
|
||||
if !strings.Contains(fromSnapshot, "deleted.mp3") {
|
||||
t.Fatalf("snapshot result = %s", fromSnapshot)
|
||||
}
|
||||
if _, err := ScanLibraryFolder(""); err == nil {
|
||||
t.Fatal("expected empty folder scan error")
|
||||
}
|
||||
fileInsteadOfFolder := filepath.Join(dir, "file.flac")
|
||||
if err := os.WriteFile(fileInsteadOfFolder, []byte("audio"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ScanLibraryFolder(fileInsteadOfFolder); err == nil {
|
||||
t.Fatal("expected not folder error")
|
||||
}
|
||||
CancelLibraryScan()
|
||||
SetLibraryCoverCacheDir("")
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func TestLogBufferExportedHelpersAndRedaction(t *testing.T) {
|
||||
ClearLogs()
|
||||
SetLoggingEnabled(false)
|
||||
LogInfo("test", "ignored access_token=secret")
|
||||
LogError("test", "Authorization: Bearer secret-token api_key=value")
|
||||
if GetLogCount() != 1 {
|
||||
t.Fatalf("disabled logging should keep errors only, got %d", GetLogCount())
|
||||
}
|
||||
|
||||
SetLoggingEnabled(true)
|
||||
defer SetLoggingEnabled(false)
|
||||
LogDebug("debug", "client_secret=secret")
|
||||
LogWarn("warn", "warning password=secret")
|
||||
GoLog("[GoTag] success token=abc")
|
||||
|
||||
var entries []LogEntry
|
||||
if err := json.Unmarshal([]byte(GetLogs()), &entries); err != nil {
|
||||
t.Fatalf("GetLogs JSON: %v", err)
|
||||
}
|
||||
if len(entries) < 4 {
|
||||
t.Fatalf("expected log entries, got %#v", entries)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.Contains(entry.Message, "secret-token") || strings.Contains(entry.Message, "api_key=value") || strings.Contains(entry.Message, "password=secret") {
|
||||
t.Fatalf("log was not redacted: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
sinceJSON := GetLogsSince(1)
|
||||
if !strings.Contains(sinceJSON, `"next_index"`) || !strings.Contains(sinceJSON, `"logs"`) {
|
||||
t.Fatalf("GetLogsSince = %q", sinceJSON)
|
||||
}
|
||||
if emptyJSON := GetLogsSince(999); !strings.Contains(emptyJSON, `"logs":[]`) {
|
||||
t.Fatalf("GetLogsSince empty = %q", emptyJSON)
|
||||
}
|
||||
if negativeJSON := GetLogsSince(-5); !strings.Contains(negativeJSON, `"logs"`) {
|
||||
t.Fatalf("GetLogsSince negative = %q", negativeJSON)
|
||||
}
|
||||
|
||||
ClearLogs()
|
||||
if GetLogCount() != 0 || GetLogs() != "[]" {
|
||||
t.Fatalf("logs were not cleared: count=%d logs=%s", GetLogCount(), GetLogs())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressItemHelpersAndWriter(t *testing.T) {
|
||||
ClearAllItemProgress()
|
||||
itemID := "progress-writer"
|
||||
StartItemProgress(itemID)
|
||||
SetItemBytesTotal(itemID, int64(progressUpdateThreshold*2))
|
||||
SetItemBytesReceived(itemID, int64(progressUpdateThreshold))
|
||||
|
||||
progressJSON := GetItemProgress(itemID)
|
||||
if !strings.Contains(progressJSON, `"bytes_received":131072`) || !strings.Contains(progressJSON, `"progress":0.5`) {
|
||||
t.Fatalf("GetItemProgress = %q", progressJSON)
|
||||
}
|
||||
if missing := GetItemProgress("missing"); missing != "{}" {
|
||||
t.Fatalf("missing progress = %q", missing)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
writer := NewItemProgressWriter(&out, itemID)
|
||||
payload := bytes.Repeat([]byte("x"), progressUpdateThreshold+1)
|
||||
n, err := writer.Write(payload)
|
||||
if err != nil || n != len(payload) {
|
||||
t.Fatalf("progress writer = %d/%v", n, err)
|
||||
}
|
||||
if out.Len() != len(payload) {
|
||||
t.Fatalf("writer output length = %d", out.Len())
|
||||
}
|
||||
if progressJSON = GetItemProgress(itemID); !strings.Contains(progressJSON, `"bytes_received":131073`) {
|
||||
t.Fatalf("progress after writer = %q", progressJSON)
|
||||
}
|
||||
|
||||
cancelDownload(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
n, err = writer.Write([]byte("cancelled"))
|
||||
if n != 0 || !errors.Is(err, ErrDownloadCancelled) {
|
||||
t.Fatalf("cancelled writer = %d/%v", n, err)
|
||||
}
|
||||
|
||||
ClearAllItemProgress()
|
||||
}
|
||||
|
||||
func TestRunWithTimeoutBranches(t *testing.T) {
|
||||
if _, err := RunWithTimeout(nil, "1 + 1", time.Millisecond); err == nil {
|
||||
t.Fatal("expected nil VM error")
|
||||
}
|
||||
|
||||
vm := goja.New()
|
||||
value, err := RunWithTimeout(vm, "1 + 2", time.Second)
|
||||
if err != nil || value.ToInteger() != 3 {
|
||||
t.Fatalf("RunWithTimeout success = %v/%v", value, err)
|
||||
}
|
||||
|
||||
timeoutVM := goja.New()
|
||||
_, err = RunWithTimeoutAndRecover(timeoutVM, "for (;;) {}", 10*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error")
|
||||
}
|
||||
if !IsTimeoutError(&JSExecutionError{Message: "timeout", IsTimeout: true}) {
|
||||
t.Fatal("JSExecutionError should be recognized as timeout")
|
||||
}
|
||||
if IsTimeoutError(errors.New("plain")) {
|
||||
t.Fatal("plain error should not be timeout")
|
||||
}
|
||||
if (&JSExecutionError{Message: "boom"}).Error() != "boom" {
|
||||
t.Fatal("JSExecutionError Error mismatch")
|
||||
}
|
||||
}
|
||||
@@ -26,25 +26,54 @@ const (
|
||||
LyricsProviderMusixmatch = "musixmatch"
|
||||
LyricsProviderAppleMusic = "apple_music"
|
||||
LyricsProviderQQMusic = "qqmusic"
|
||||
LyricsProviderSpotify = "spotify"
|
||||
LyricsProviderDeezer = "deezer"
|
||||
LyricsProviderYouTube = "youtube"
|
||||
LyricsProviderKugou = "kugou"
|
||||
LyricsProviderGenius = "genius"
|
||||
)
|
||||
|
||||
var DefaultLyricsProviders = []string{
|
||||
LyricsProviderLRCLIB,
|
||||
LyricsProviderMusixmatch,
|
||||
LyricsProviderNetease,
|
||||
LyricsProviderAppleMusic,
|
||||
LyricsProviderQQMusic,
|
||||
}
|
||||
|
||||
var (
|
||||
lyricsProvidersMu sync.RWMutex
|
||||
lyricsProviders []string // ordered list of enabled providers
|
||||
appVersionMu sync.RWMutex
|
||||
appVersion string
|
||||
)
|
||||
|
||||
func SetAppVersion(version string) {
|
||||
normalized := strings.TrimSpace(version)
|
||||
|
||||
appVersionMu.Lock()
|
||||
defer appVersionMu.Unlock()
|
||||
appVersion = normalized
|
||||
}
|
||||
|
||||
func GetAppVersion() string {
|
||||
appVersionMu.RLock()
|
||||
defer appVersionMu.RUnlock()
|
||||
return appVersion
|
||||
}
|
||||
|
||||
func appUserAgent() string {
|
||||
version := GetAppVersion()
|
||||
|
||||
if version == "" {
|
||||
return "SpotiFLAC-Mobile"
|
||||
}
|
||||
|
||||
return "SpotiFLAC-Mobile/" + version
|
||||
}
|
||||
|
||||
type LyricsFetchOptions struct {
|
||||
IncludeTranslationNetease bool `json:"include_translation_netease"`
|
||||
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
|
||||
MultiPersonWordByWord bool `json:"multi_person_word_by_word"`
|
||||
AppleElrcWordSync bool `json:"apple_elrc_word_sync"`
|
||||
MusixmatchLanguage string `json:"musixmatch_language,omitempty"`
|
||||
}
|
||||
|
||||
@@ -52,9 +81,12 @@ var defaultLyricsFetchOptions = LyricsFetchOptions{
|
||||
IncludeTranslationNetease: false,
|
||||
IncludeRomanizationNetease: false,
|
||||
MultiPersonWordByWord: true,
|
||||
AppleElrcWordSync: false,
|
||||
MusixmatchLanguage: "",
|
||||
}
|
||||
|
||||
var instrumentalTrackPattern = regexp.MustCompile(`(?i)(?:^|[\s\[(\-])(?:instrumental|inst\.?)(?:[\s\])]|$)`)
|
||||
|
||||
var (
|
||||
lyricsFetchOptionsMu sync.RWMutex
|
||||
lyricsFetchOptions = defaultLyricsFetchOptions
|
||||
@@ -75,6 +107,11 @@ func SetLyricsProviderOrder(providers []string) {
|
||||
LyricsProviderMusixmatch: true,
|
||||
LyricsProviderAppleMusic: true,
|
||||
LyricsProviderQQMusic: true,
|
||||
LyricsProviderSpotify: true,
|
||||
LyricsProviderDeezer: true,
|
||||
LyricsProviderYouTube: true,
|
||||
LyricsProviderKugou: true,
|
||||
LyricsProviderGenius: true,
|
||||
}
|
||||
|
||||
var valid []string
|
||||
@@ -105,10 +142,15 @@ func GetLyricsProviderOrder() []string {
|
||||
func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||
return []map[string]interface{}{
|
||||
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics"},
|
||||
{"id": LyricsProviderSpotify, "name": "Spotify", "has_proxy_dependency": true, "description": "Spotify synced lyrics"},
|
||||
{"id": LyricsProviderDeezer, "name": "Deezer", "has_proxy_dependency": true, "description": "Deezer lyrics"},
|
||||
{"id": LyricsProviderYouTube, "name": "YouTube", "has_proxy_dependency": true, "description": "YouTube lyrics"},
|
||||
{"id": LyricsProviderKugou, "name": "Kugou", "has_proxy_dependency": true, "description": "Kugou lyrics"},
|
||||
{"id": LyricsProviderGenius, "name": "Genius", "has_proxy_dependency": true, "description": "Genius lyrics"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,12 +168,18 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
||||
|
||||
lyricsFetchOptionsMu.Lock()
|
||||
defer lyricsFetchOptionsMu.Unlock()
|
||||
changed := lyricsFetchOptions != normalized
|
||||
lyricsFetchOptions = normalized
|
||||
|
||||
GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v musixmatch_lang=%q\n",
|
||||
if changed {
|
||||
globalLyricsCache.ClearAll()
|
||||
}
|
||||
|
||||
GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v apple_elrc=%v musixmatch_lang=%q\n",
|
||||
normalized.IncludeTranslationNetease,
|
||||
normalized.IncludeRomanizationNetease,
|
||||
normalized.MultiPersonWordByWord,
|
||||
normalized.AppleElrcWordSync,
|
||||
normalized.MusixmatchLanguage,
|
||||
)
|
||||
}
|
||||
@@ -385,6 +433,16 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
primaryArtist := normalizeArtistName(artistName)
|
||||
fetchOptions := GetLyricsFetchOptions()
|
||||
|
||||
if isLikelyInstrumentalTrack(trackName) {
|
||||
GoLog("[Lyrics] Track marked instrumental by title heuristic, skipping lyrics search: %s - %s\n", artistName, trackName)
|
||||
instrumental := &LyricsResponse{
|
||||
Instrumental: true,
|
||||
Source: "Heuristic: Instrumental",
|
||||
}
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, instrumental)
|
||||
return instrumental, nil
|
||||
}
|
||||
|
||||
extManager := getExtensionManager()
|
||||
var extensionProviders []*extensionProviderWrapper
|
||||
if extManager != nil {
|
||||
@@ -495,9 +553,9 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
|
||||
case LyricsProviderAppleMusic:
|
||||
appleClient := NewAppleMusicClient()
|
||||
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync)
|
||||
}
|
||||
|
||||
case LyricsProviderQQMusic:
|
||||
@@ -507,6 +565,53 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
lyrics, err = qqClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
}
|
||||
|
||||
case LyricsProviderSpotify:
|
||||
spotifyClient := NewSpotifyLyricsClient()
|
||||
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
|
||||
}
|
||||
if err != nil && simplifiedTrack != trackName {
|
||||
lyrics, err = spotifyClient.FetchLyrics("", simplifiedTrack, primaryArtist, durationSec)
|
||||
}
|
||||
|
||||
case LyricsProviderDeezer:
|
||||
deezerClient := NewDeezerLyricsClient()
|
||||
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
|
||||
}
|
||||
|
||||
case LyricsProviderYouTube:
|
||||
youtubeClient := NewYouTubeLyricsClient()
|
||||
lyrics, err = youtubeClient.FetchLyrics(trackName, primaryArtist, durationSec)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = youtubeClient.FetchLyrics(trackName, artistName, durationSec)
|
||||
}
|
||||
if err != nil && simplifiedTrack != trackName {
|
||||
lyrics, err = youtubeClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
|
||||
}
|
||||
|
||||
case LyricsProviderKugou:
|
||||
kugouClient := NewKugouLyricsClient()
|
||||
lyrics, err = kugouClient.FetchLyrics(trackName, primaryArtist, durationSec)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = kugouClient.FetchLyrics(trackName, artistName, durationSec)
|
||||
}
|
||||
if err != nil && simplifiedTrack != trackName {
|
||||
lyrics, err = kugouClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
|
||||
}
|
||||
|
||||
case LyricsProviderGenius:
|
||||
geniusClient := NewGeniusLyricsClient()
|
||||
lyrics, err = geniusClient.FetchLyrics(trackName, primaryArtist, durationSec)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = geniusClient.FetchLyrics(trackName, artistName, durationSec)
|
||||
}
|
||||
if err != nil && simplifiedTrack != trackName {
|
||||
lyrics, err = geniusClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
|
||||
}
|
||||
|
||||
default:
|
||||
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
|
||||
continue
|
||||
@@ -821,6 +926,15 @@ func normalizeArtistName(name string) string {
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
|
||||
func isLikelyInstrumentalTrack(name string) bool {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
return instrumentalTrackPattern.MatchString(trimmed)
|
||||
}
|
||||
|
||||
func SaveLRCFile(audioFilePath, lrcContent string) (string, error) {
|
||||
if lrcContent == "" {
|
||||
return "", fmt.Errorf("empty LRC content")
|
||||
|
||||
@@ -114,7 +114,7 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
@@ -147,7 +147,8 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -172,25 +173,25 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
||||
return bodyStr, nil
|
||||
}
|
||||
|
||||
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
||||
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool, preserveWordTiming bool) (string, error) {
|
||||
var paxResp paxResponse
|
||||
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
|
||||
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
|
||||
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord, preserveWordTiming), nil
|
||||
}
|
||||
|
||||
var directLyrics []paxLyrics
|
||||
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
|
||||
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
|
||||
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord, preserveWordTiming), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to parse pax lyrics response")
|
||||
}
|
||||
|
||||
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) {
|
||||
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail, preserveWordTiming bool) {
|
||||
lastStart := ""
|
||||
|
||||
for _, syllable := range details {
|
||||
if syllable.Timestamp != nil {
|
||||
if preserveWordTiming && syllable.Timestamp != nil {
|
||||
start := fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.Timestamp)))
|
||||
if start != lastStart {
|
||||
builder.WriteString(start)
|
||||
@@ -203,13 +204,13 @@ func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) {
|
||||
builder.WriteString(" ")
|
||||
}
|
||||
|
||||
if syllable.EndTime != nil {
|
||||
if preserveWordTiming && syllable.EndTime != nil {
|
||||
builder.WriteString(fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.EndTime))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool) string {
|
||||
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool, preserveWordTiming bool) string {
|
||||
var sb strings.Builder
|
||||
|
||||
for i, line := range content {
|
||||
@@ -229,11 +230,11 @@ func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByW
|
||||
}
|
||||
}
|
||||
|
||||
appendPaxLyricDetail(&sb, line.Text)
|
||||
appendPaxLyricDetail(&sb, line.Text, preserveWordTiming)
|
||||
|
||||
if line.Background && multiPersonWordByWord && len(line.BackgroundText) > 0 {
|
||||
sb.WriteString("\n[bg:")
|
||||
appendPaxLyricDetail(&sb, line.BackgroundText)
|
||||
appendPaxLyricDetail(&sb, line.BackgroundText, preserveWordTiming)
|
||||
sb.WriteString("]")
|
||||
}
|
||||
} else {
|
||||
@@ -252,6 +253,7 @@ func (c *AppleMusicClient) FetchLyrics(
|
||||
artistName string,
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
preserveWordTiming bool,
|
||||
) (*LyricsResponse, error) {
|
||||
songID, err := c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
@@ -266,7 +268,7 @@ func (c *AppleMusicClient) FetchLyrics(
|
||||
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
|
||||
}
|
||||
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, preserveWordTiming)
|
||||
if err != nil {
|
||||
lrcText = rawLyrics
|
||||
}
|
||||
|
||||
@@ -16,32 +16,6 @@ type MusixmatchClient struct {
|
||||
baseURL string
|
||||
}
|
||||
|
||||
type musixmatchSearchResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
SongName string `json:"songName"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
Artwork string `json:"artwork"`
|
||||
ReleaseDate string `json:"releaseDate"`
|
||||
Duration int `json:"duration"`
|
||||
URL string `json:"url"`
|
||||
AlbumID int64 `json:"albumId"`
|
||||
HasSyncedLyrics bool `json:"hasSyncedLyrics"`
|
||||
HasUnsyncedLyrics bool `json:"hasUnsyncedLyrics"`
|
||||
AvailableLanguages []string `json:"availableLanguages"`
|
||||
OriginalLanguage string `json:"originalLanguage"`
|
||||
SyncedLyrics *musixmatchLyricsResponse `json:"syncedLyrics"`
|
||||
UnsyncedLyrics *musixmatchLyricsResponse `json:"unsyncedLyrics"`
|
||||
}
|
||||
|
||||
type musixmatchLyricsResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Duration int `json:"duration"`
|
||||
Language string `json:"language"`
|
||||
UpdatedTime string `json:"updatedTime"`
|
||||
Lyrics string `json:"lyrics"`
|
||||
}
|
||||
|
||||
func NewMusixmatchClient() *MusixmatchClient {
|
||||
return &MusixmatchClient{
|
||||
httpClient: NewMetadataHTTPClient(15 * time.Second),
|
||||
@@ -72,7 +46,7 @@ func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, dura
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -70,7 +70,7 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
|
||||
for k, v := range neteaseHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -109,7 +109,7 @@ func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includ
|
||||
for k, v := range neteaseHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SpotifyLyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type DeezerLyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type YouTubeLyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type KugouLyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type GeniusLyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type spotifyLyricsSearchResult struct {
|
||||
TrackID string `json:"trackId"`
|
||||
Name string `json:"name"`
|
||||
ArtistName string `json:"artistName"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
type youtubeLyricsSearchResult struct {
|
||||
VideoID string `json:"videoId"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
type kugouLyricsSearchResult struct {
|
||||
Hash string `json:"hash"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Duration float64 `json:"duration"`
|
||||
}
|
||||
|
||||
type geniusSearchResponse struct {
|
||||
Response struct {
|
||||
Sections []struct {
|
||||
Hits []struct {
|
||||
Type string `json:"type"`
|
||||
Result struct {
|
||||
Title string `json:"title"`
|
||||
ArtistNames string `json:"artist_names"`
|
||||
PrimaryArtistNames string `json:"primary_artist_names"`
|
||||
URL string `json:"url"`
|
||||
} `json:"result"`
|
||||
} `json:"hits"`
|
||||
} `json:"sections"`
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
type paxsenixLyricsObject struct {
|
||||
Type string `json:"type"`
|
||||
Content []paxLyrics `json:"content"`
|
||||
Lyrics []paxLyrics `json:"lyrics"`
|
||||
LyricsText string `json:"lyrics_text"`
|
||||
PlainLyrics string `json:"plain_lyrics"`
|
||||
}
|
||||
|
||||
func NewSpotifyLyricsClient() *SpotifyLyricsClient {
|
||||
return &SpotifyLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
|
||||
}
|
||||
|
||||
func NewDeezerLyricsClient() *DeezerLyricsClient {
|
||||
return &DeezerLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
|
||||
}
|
||||
|
||||
func NewYouTubeLyricsClient() *YouTubeLyricsClient {
|
||||
return &YouTubeLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
|
||||
}
|
||||
|
||||
func NewKugouLyricsClient() *KugouLyricsClient {
|
||||
return &KugouLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
|
||||
}
|
||||
|
||||
func NewGeniusLyricsClient() *GeniusLyricsClient {
|
||||
return &GeniusLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
|
||||
}
|
||||
|
||||
func fetchPaxsenixBody(httpClient *http.Client, endpoint string, params url.Values) (string, error) {
|
||||
fullURL := endpoint
|
||||
if len(params) > 0 {
|
||||
fullURL += "?" + params.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, errMsg)
|
||||
}
|
||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||
return "", fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
if trimmed == "" {
|
||||
return "", fmt.Errorf("empty response")
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
func parsePaxsenixLyricsPayload(raw, provider string, multiPersonWordByWord bool) (*LyricsResponse, error) {
|
||||
var lrcPayload string
|
||||
if err := json.Unmarshal([]byte(raw), &lrcPayload); err == nil {
|
||||
lrcPayload = strings.TrimSpace(lrcPayload)
|
||||
if lrcPayload == "" {
|
||||
return nil, fmt.Errorf("%s returned empty lyrics", provider)
|
||||
}
|
||||
return lyricsResponseFromText(lrcPayload, provider), nil
|
||||
}
|
||||
|
||||
var rawObject map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &rawObject); err == nil {
|
||||
for _, key := range []string{"lyrics", "lyric", "lyrics_text", "plain_lyrics"} {
|
||||
var value string
|
||||
if rawValue, ok := rawObject[key]; ok && json.Unmarshal(rawValue, &value) == nil {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return lyricsResponseFromText(value, provider), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var payload paxsenixLyricsObject
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err == nil {
|
||||
switch {
|
||||
case strings.TrimSpace(payload.LyricsText) != "":
|
||||
return lyricsResponseFromText(payload.LyricsText, provider), nil
|
||||
case len(payload.Lyrics) > 0:
|
||||
return lyricsResponseFromText(formatPaxContent("Syllable", payload.Lyrics, multiPersonWordByWord, true), provider), nil
|
||||
case len(payload.Content) > 0:
|
||||
lyricsType := payload.Type
|
||||
if lyricsType == "" {
|
||||
lyricsType = "Syllable"
|
||||
}
|
||||
return lyricsResponseFromText(formatPaxContent(lyricsType, payload.Content, multiPersonWordByWord, true), provider), nil
|
||||
case strings.TrimSpace(payload.PlainLyrics) != "":
|
||||
return lyricsResponseFromText(payload.PlainLyrics, provider), nil
|
||||
}
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[") {
|
||||
return lyricsResponseFromText(trimmed, provider), nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decode %s lyrics response", provider)
|
||||
}
|
||||
|
||||
func lyricsResponseFromText(text, provider string) *LyricsResponse {
|
||||
lines := parseSyncedLyrics(text)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||
Provider: provider,
|
||||
Source: provider,
|
||||
}
|
||||
}
|
||||
|
||||
plainLines := plainTextLyricsLines(text)
|
||||
if len(plainLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: plainLines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: text,
|
||||
Provider: provider,
|
||||
Source: provider,
|
||||
}
|
||||
}
|
||||
|
||||
return &LyricsResponse{Provider: provider, Source: provider}
|
||||
}
|
||||
|
||||
func normalizeSpotifyLyricsID(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || strings.HasPrefix(strings.ToLower(raw), "deezer:") {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(raw), "spotify:") {
|
||||
parts := strings.Split(raw, ":")
|
||||
raw = parts[len(parts)-1]
|
||||
}
|
||||
if strings.Contains(raw, "spotify.com/track/") {
|
||||
raw = extractSpotifyIDFromURL(raw)
|
||||
}
|
||||
raw = strings.TrimSpace(strings.Split(raw, "?")[0])
|
||||
if regexpSpotifyTrackID.MatchString(raw) {
|
||||
return raw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var regexpSpotifyTrackID = regexp.MustCompile(`^[A-Za-z0-9]{22}$`)
|
||||
|
||||
func (c *SpotifyLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := strings.TrimSpace(trackName + " " + artistName)
|
||||
if query == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/spotify/search", params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("spotify search failed: %w", err)
|
||||
}
|
||||
|
||||
var results []spotifyLyricsSearchResult
|
||||
if err := json.Unmarshal([]byte(raw), &results); err != nil {
|
||||
return "", fmt.Errorf("failed to decode spotify search: %w", err)
|
||||
}
|
||||
best := selectBestSpotifyLyricsSearchResult(results, trackName, artistName, durationSec)
|
||||
if best == nil || strings.TrimSpace(best.TrackID) == "" {
|
||||
return "", fmt.Errorf("no songs found on spotify")
|
||||
}
|
||||
return strings.TrimSpace(best.TrackID), nil
|
||||
}
|
||||
|
||||
func selectBestSpotifyLyricsSearchResult(results []spotifyLyricsSearchResult, trackName, artistName string, durationSec float64) *spotifyLyricsSearchResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bestIndex := 0
|
||||
bestScore := -1
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
score := scoreLyricsSearchCandidate(result.Name, result.ArtistName, parseClockDuration(result.Duration), trackName, artistName, durationSec)
|
||||
if score > bestScore {
|
||||
bestIndex = i
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
return &results[bestIndex]
|
||||
}
|
||||
|
||||
func (c *SpotifyLyricsClient) FetchLyricsByID(trackID string) (*LyricsResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("id", trackID)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/spotify/lyrics", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("spotify lyrics fetch failed: %w", err)
|
||||
}
|
||||
return parsePaxsenixLyricsPayload(raw, "Spotify", false)
|
||||
}
|
||||
|
||||
func (c *SpotifyLyricsClient) FetchLyrics(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
trackID := normalizeSpotifyLyricsID(spotifyID)
|
||||
if trackID == "" {
|
||||
var err error
|
||||
trackID, err = c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return c.FetchLyricsByID(trackID)
|
||||
}
|
||||
|
||||
func normalizeDeezerLyricsID(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if strings.HasPrefix(strings.ToLower(raw), "deezer:") {
|
||||
raw = strings.TrimSpace(raw[len("deezer:"):])
|
||||
}
|
||||
if strings.Contains(raw, "deezer.com/") {
|
||||
raw = extractDeezerIDFromURL(raw)
|
||||
}
|
||||
raw = strings.TrimSpace(strings.Split(raw, "?")[0])
|
||||
if _, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
return raw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *DeezerLyricsClient) FetchLyricsByID(trackID string, multiPersonWordByWord bool) (*LyricsResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("id", trackID)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/deezer/lyrics", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("deezer lyrics fetch failed: %w", err)
|
||||
}
|
||||
return parsePaxsenixLyricsPayload(raw, "Deezer", multiPersonWordByWord)
|
||||
}
|
||||
|
||||
func (c *DeezerLyricsClient) FetchLyrics(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
deezerID := normalizeDeezerLyricsID(spotifyID)
|
||||
if deezerID == "" {
|
||||
spotifyTrackID := normalizeSpotifyLyricsID(spotifyID)
|
||||
if spotifyTrackID == "" {
|
||||
return nil, fmt.Errorf("deezer provider needs a deezer id or spotify id")
|
||||
}
|
||||
resolvedID, err := NewSongLinkClient().GetDeezerIDFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve deezer id: %w", err)
|
||||
}
|
||||
deezerID = normalizeDeezerLyricsID(resolvedID)
|
||||
}
|
||||
if deezerID == "" {
|
||||
return nil, fmt.Errorf("deezer id unavailable")
|
||||
}
|
||||
return c.FetchLyricsByID(deezerID, true)
|
||||
}
|
||||
|
||||
func (c *YouTubeLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := strings.TrimSpace(trackName + " " + artistName)
|
||||
if query == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/youtube/search", params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("youtube search failed: %w", err)
|
||||
}
|
||||
|
||||
var results []youtubeLyricsSearchResult
|
||||
if err := json.Unmarshal([]byte(raw), &results); err != nil {
|
||||
return "", fmt.Errorf("failed to decode youtube search: %w", err)
|
||||
}
|
||||
best := selectBestYouTubeLyricsSearchResult(results, trackName, artistName, durationSec)
|
||||
if best == nil || strings.TrimSpace(best.VideoID) == "" {
|
||||
return "", fmt.Errorf("no songs found on youtube")
|
||||
}
|
||||
return strings.TrimSpace(best.VideoID), nil
|
||||
}
|
||||
|
||||
func selectBestYouTubeLyricsSearchResult(results []youtubeLyricsSearchResult, trackName, artistName string, durationSec float64) *youtubeLyricsSearchResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bestIndex := 0
|
||||
bestScore := -1
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
score := scoreLyricsSearchCandidate(result.Title, result.Author, parseClockDuration(result.Duration), trackName, artistName, durationSec)
|
||||
if score > bestScore {
|
||||
bestIndex = i
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
return &results[bestIndex]
|
||||
}
|
||||
|
||||
func (c *YouTubeLyricsClient) FetchLyrics(trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
videoID, err := c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("id", videoID)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/youtube/lyrics", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("youtube lyrics fetch failed: %w", err)
|
||||
}
|
||||
return parsePaxsenixLyricsPayload(raw, "YouTube", false)
|
||||
}
|
||||
|
||||
func (c *KugouLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := strings.TrimSpace(trackName + " " + artistName)
|
||||
if query == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/kugou/search", params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("kugou search failed: %w", err)
|
||||
}
|
||||
|
||||
var results []kugouLyricsSearchResult
|
||||
if err := json.Unmarshal([]byte(raw), &results); err != nil {
|
||||
return "", fmt.Errorf("failed to decode kugou search: %w", err)
|
||||
}
|
||||
best := selectBestKugouLyricsSearchResult(results, trackName, artistName, durationSec)
|
||||
if best == nil || strings.TrimSpace(best.Hash) == "" {
|
||||
return "", fmt.Errorf("no songs found on kugou")
|
||||
}
|
||||
return strings.TrimSpace(best.Hash), nil
|
||||
}
|
||||
|
||||
func selectBestKugouLyricsSearchResult(results []kugouLyricsSearchResult, trackName, artistName string, durationSec float64) *kugouLyricsSearchResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bestIndex := 0
|
||||
bestScore := -1
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
score := scoreLyricsSearchCandidate(result.Title, result.Artist, result.Duration, trackName, artistName, durationSec)
|
||||
if score > bestScore {
|
||||
bestIndex = i
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
return &results[bestIndex]
|
||||
}
|
||||
|
||||
func (c *KugouLyricsClient) FetchLyrics(trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
hash, err := c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("id", hash)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/kugou/lyrics", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kugou lyrics fetch failed: %w", err)
|
||||
}
|
||||
return parsePaxsenixLyricsPayload(raw, "Kugou", false)
|
||||
}
|
||||
|
||||
func (c *GeniusLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := strings.TrimSpace(trackName + " " + artistName)
|
||||
if query == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
params.Set("per_page", "10")
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://genius.com/api/search/multi", params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("genius search failed: %w", err)
|
||||
}
|
||||
|
||||
var results geniusSearchResponse
|
||||
if err := json.Unmarshal([]byte(raw), &results); err != nil {
|
||||
return "", fmt.Errorf("failed to decode genius search: %w", err)
|
||||
}
|
||||
|
||||
bestURL := ""
|
||||
bestScore := -1
|
||||
for _, section := range results.Response.Sections {
|
||||
for _, hit := range section.Hits {
|
||||
if hit.Type != "song" || strings.TrimSpace(hit.Result.URL) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
artist := hit.Result.PrimaryArtistNames
|
||||
if strings.TrimSpace(artist) == "" {
|
||||
artist = hit.Result.ArtistNames
|
||||
}
|
||||
score := scoreLyricsSearchCandidate(hit.Result.Title, artist, 0, trackName, artistName, durationSec)
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestURL = strings.TrimSpace(hit.Result.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestURL == "" {
|
||||
return "", fmt.Errorf("no songs found on genius")
|
||||
}
|
||||
return bestURL, nil
|
||||
}
|
||||
|
||||
func (c *GeniusLyricsClient) FetchLyrics(trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
geniusURL, err := c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("url", geniusURL)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/genius/lyrics", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("genius lyrics fetch failed: %w", err)
|
||||
}
|
||||
return parsePaxsenixLyricsPayload(raw, "Genius", false)
|
||||
}
|
||||
|
||||
func scoreLyricsSearchCandidate(candidateTrack, candidateArtist string, candidateDuration float64, trackName, artistName string, durationSec float64) int {
|
||||
normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName)))
|
||||
normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName)))
|
||||
candidateTrack = strings.ToLower(strings.TrimSpace(simplifyTrackName(candidateTrack)))
|
||||
candidateArtist = strings.ToLower(strings.TrimSpace(normalizeArtistName(candidateArtist)))
|
||||
|
||||
score := 0
|
||||
switch {
|
||||
case candidateTrack == normalizedTrack:
|
||||
score += 50
|
||||
case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack):
|
||||
score += 25
|
||||
}
|
||||
|
||||
switch {
|
||||
case candidateArtist == normalizedArtist:
|
||||
score += 60
|
||||
case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist):
|
||||
score += 30
|
||||
}
|
||||
|
||||
if durationSec > 0 && candidateDuration > 0 {
|
||||
diff := math.Abs(candidateDuration - durationSec)
|
||||
if diff <= durationToleranceSec {
|
||||
score += 20
|
||||
}
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func parseClockDuration(value string) float64 {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
parts := strings.Split(value, ":")
|
||||
total := 0
|
||||
for _, part := range parts {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(part))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
total = total*60 + n
|
||||
}
|
||||
return float64(total)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, dura
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -87,7 +87,7 @@ func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (st
|
||||
if len(response.Lyrics) == 0 {
|
||||
return "", fmt.Errorf("qq metadata lyrics response was empty")
|
||||
}
|
||||
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
|
||||
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord, true), nil
|
||||
}
|
||||
|
||||
func (c *QQMusicClient) FetchLyrics(
|
||||
@@ -106,7 +106,7 @@ func (c *QQMusicClient) FetchLyrics(
|
||||
|
||||
lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
|
||||
if err != nil {
|
||||
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
|
||||
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, true); fallbackErr == nil {
|
||||
lrcText = fallback
|
||||
} else {
|
||||
lrcText = rawLyrics
|
||||
|
||||