mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 03:15:51 +02:00
Compare commits
299 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe72286273 | |||
| 2b8ec744dd | |||
| 5f11f5b114 | |||
| 61f62363b3 | |||
| 3278e32711 | |||
| 0be6455d46 | |||
| 0bf5a39a92 | |||
| 5424648158 | |||
| dcfd95f276 | |||
| 4d6f7d8b08 | |||
| 2c2cf8cdf8 | |||
| 08c738dc69 | |||
| eb36b0bb7b | |||
| 3fd14e21eb | |||
| 408895b607 | |||
| 1a01147a95 | |||
| 8950907428 | |||
| eb40a88437 | |||
| 7f82049beb | |||
| c0c1d745f3 | |||
| c2b38a7c5a | |||
| ae8638a4b2 | |||
| b864fafa82 | |||
| ee5ab1a751 | |||
| 64b884e27a | |||
| dc8bb2cbc2 | |||
| d882fc292c | |||
| 5dc0980ced | |||
| 1cd668c869 | |||
| a827ebf6f4 | |||
| 3917ae02e2 | |||
| bd14c7dc63 | |||
| e0e28aee38 | |||
| 1550eedc12 | |||
| b2074dfd02 | |||
| e9171d6f21 | |||
| ef60bba2e1 | |||
| 12fb942f16 | |||
| 3a2481e8b2 | |||
| bede5ae8d7 | |||
| 445b186e3b | |||
| 354fe61b85 | |||
| 95f5ae610e | |||
| 2e806a28b9 | |||
| 2ab0350733 | |||
| ce813bc216 | |||
| 21fe047e00 | |||
| 8558450378 | |||
| f9e68b628d | |||
| 50509d0a16 | |||
| c1c0494912 | |||
| 58e615462c | |||
| f0bf769f0d | |||
| 423d50cfb5 | |||
| 2f4a62e03c | |||
| e64bea41e6 | |||
| f0acda0f01 | |||
| af4e4561ec | |||
| 1787059f42 | |||
| b2705cb2ae | |||
| f236d72a19 | |||
| cf270a36ff | |||
| 6d932386b0 | |||
| 9c054b9e3a | |||
| d9f0007a2d | |||
| ee35f52baf | |||
| 21347420f3 | |||
| 26987459f3 | |||
| 897388853b | |||
| ef52332b8b | |||
| 1489378ffd | |||
| ccc93f881a | |||
| ded8b68098 | |||
| 983be8b37a | |||
| 7b22bbf25f | |||
| 06f2b9ec97 | |||
| 7fee4cea4f | |||
| 526897b23b | |||
| c10c2a290c | |||
| fb5204b0a6 | |||
| 9db4048bc0 | |||
| 63c68b4d4d | |||
| 953ef37882 | |||
| da85a2dcc2 | |||
| 49869792cf | |||
| fb2dda1ed1 | |||
| fad4c4ea36 | |||
| 6b5345a6e5 | |||
| ca413a16fa | |||
| b8b670642c | |||
| 2a2e2924eb | |||
| adea3de737 | |||
| 7d300a39c9 | |||
| 688a5f2add | |||
| d736e5aafe | |||
| 3a536ad348 | |||
| 5dedeb4971 | |||
| 7624e24ea6 | |||
| 7b248d8ab4 | |||
| fdb2009856 | |||
| 8419a75b04 | |||
| 5d474d6fe8 | |||
| e597505a1c | |||
| 8675d263e7 | |||
| 1ce66b9e03 | |||
| cfda124995 | |||
| 212f1cacca | |||
| dd89de7cad | |||
| 8b4372dc7f | |||
| 2a25557632 | |||
| 0cbb339948 | |||
| 1496f51e30 | |||
| d1c5fe0605 | |||
| 56786f60ff | |||
| af5d36f69f | |||
| e40da71ef8 | |||
| 26b8bf422c | |||
| 0a545706bd | |||
| 9ebac610c7 | |||
| 5fc8a6af2a | |||
| 8e68af79aa | |||
| 6246e6e821 | |||
| 421d5ffdc8 | |||
| b82dabe316 | |||
| ffdaf14ba5 | |||
| f52527a41b | |||
| 56a89c5fc6 | |||
| 4f5163be01 | |||
| 822c094c8c | |||
| 1623f443bb | |||
| aa47bc4499 | |||
| f461322842 | |||
| cce05a0077 | |||
| 98dc868f47 | |||
| 821a41c10e | |||
| 853ccd657a | |||
| 680fc81db2 | |||
| 36470eda24 | |||
| a37dd6c8cb | |||
| 588f742871 | |||
| ff25a10e5b | |||
| 499457f66a | |||
| 6d15050009 | |||
| 5ba30031c3 | |||
| 82c0eef504 | |||
| 616267e997 | |||
| 161b0c8c21 | |||
| facd185d6c | |||
| 42858bf336 | |||
| 716be88caf | |||
| b296726a9d | |||
| 092f18d7a5 | |||
| f1ef33e319 | |||
| fc9bc95418 | |||
| c61e64f332 | |||
| 70ebb8ef1a | |||
| a4c6a92478 | |||
| 76b453e535 | |||
| 19acdd87f5 | |||
| 492e1335ef | |||
| 23cde7add3 | |||
| a20c28db25 | |||
| f07d46c49e | |||
| e9781a24a6 | |||
| 15be15ba58 | |||
| 0952b76e11 | |||
| 8011d41e53 | |||
| 5412f23d26 | |||
| 0c39ff47f2 | |||
| 537af905f6 | |||
| 6b4f70bde3 | |||
| be2b6d2c1f | |||
| 0c1a6d8f19 | |||
| 2821997260 | |||
| 0546a33b10 | |||
| deb98d8dfb | |||
| 72c658eda7 | |||
| df17f10c8a | |||
| 9cacf2dc8e | |||
| c7bc9f5b1c | |||
| 49ba8ae0d2 | |||
| 7291dbd9e2 | |||
| 3a62442ed0 | |||
| 3a1b92f9c4 | |||
| 30f97394ec | |||
| 592308c1c6 | |||
| 8bcfc63da0 | |||
| a9cfff2692 | |||
| 9e7ff56113 | |||
| 9071143bbd | |||
| 40770aff15 | |||
| 2bc5ef34ee | |||
| 6b9a3d95cd | |||
| 4fe51cef96 | |||
| d005e2e2e7 | |||
| 672ce024f8 | |||
| 8224e93447 | |||
| 1ba810fffb | |||
| 1a725d0d31 | |||
| 51c5b42a78 | |||
| 2908827018 | |||
| b985cbf694 | |||
| 1293d92896 | |||
| 705d41931d | |||
| 29de69d323 | |||
| 28727d89f6 | |||
| 4704bcf52f | |||
| 13c148fb6c | |||
| e6079452f9 | |||
| b68b7d5c9b | |||
| 741fcdb4d9 | |||
| 642f8c5398 | |||
| 1c15d5e7d3 | |||
| e71090338c | |||
| 7c0feaaae0 | |||
| 5aa3ff4bb5 | |||
| d4c83db428 | |||
| 9f2d51fd4d | |||
| 36137e8970 | |||
| 823e56926f | |||
| dd8a54dd43 | |||
| 1ff33b96fa | |||
| 4be9273768 | |||
| f458ac2162 | |||
| b5ea2bb4c1 | |||
| 284d257921 | |||
| 30bf6b7f9a | |||
| 4941b6bd23 | |||
| 33d99817ec | |||
| 37e1af50ad | |||
| 8a6efb1303 | |||
| 7823b19b89 | |||
| 2a9aa544a9 | |||
| f387c8ff85 | |||
| 7e537aec0b | |||
| 66cd465565 | |||
| 83afa40423 | |||
| 486e7eb101 | |||
| 05eb9e60d3 | |||
| dde7095644 | |||
| f1e9a2915d | |||
| ae3495d373 | |||
| 6fb2c1b688 | |||
| 1526c558e7 | |||
| 324e0f053b | |||
| 25cb33c78e | |||
| 942b6d9569 | |||
| cd46c79383 | |||
| 0bdcdcc229 | |||
| 1a5863a7fb | |||
| 701015ad55 | |||
| 63cfac626a | |||
| e6c5a21bfc | |||
| 2d80739141 | |||
| 6494102e15 | |||
| 0e6aa2efd9 | |||
| f412c216c5 | |||
| af15e3d914 | |||
| b00ff3f3f0 | |||
| 1607e6830e | |||
| 817e0bf2bd | |||
| 0f12fbce6a | |||
| 953a09d75f | |||
| 5098989614 | |||
| 5828bcffdd | |||
| ae87a7d58f | |||
| 32ab78a213 | |||
| 69583d172c | |||
| 38367c1c77 | |||
| 2f6bf91a1c | |||
| 60b062bbaf | |||
| 30e8b604a9 | |||
| 7c3ab92e17 | |||
| 37b101c70f | |||
| b7be46e6ae | |||
| bf1f79866b | |||
| a6460426a2 | |||
| 304ba14d20 | |||
| db47233d92 | |||
| 74eeb98be8 | |||
| 331da0f897 | |||
| 73964ee648 | |||
| a5e8402141 | |||
| c5e7fcf29b | |||
| d3cf6d30a7 | |||
| 74e14f7a43 | |||
| 02e347adb0 | |||
| 56983cb85b | |||
| 7917c656b0 | |||
| fc34c1e548 | |||
| f32aeaa0ff | |||
| 86097a932c | |||
| f74f24c41f | |||
| 8e99e7b07e | |||
| e06aab6e87 | |||
| a81e56fb26 | |||
| 9a09b119c5 | |||
| 4b28ca1055 | |||
| d684d9f8d1 |
@@ -66,7 +66,7 @@ jobs:
|
|||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
java-version: "17"
|
java-version: "25"
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v6
|
||||||
@@ -257,6 +257,15 @@ jobs:
|
|||||||
- name: Get Flutter dependencies
|
- name: Get Flutter dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Normalize ffmpeg plugin shell scripts (strip CRLF)
|
||||||
|
run: |
|
||||||
|
find "$HOME/.pub-cache/hosted" -path "*ffmpeg_kit_flutter_new_full*/scripts/*.sh" -type f -print0 |
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
perl -pi -e 's/\r$//' "$f"
|
||||||
|
chmod +x "$f"
|
||||||
|
echo "Normalized line endings: $f"
|
||||||
|
done
|
||||||
|
|
||||||
- name: Generate app icons
|
- name: Generate app icons
|
||||||
run: dart run flutter_launcher_icons
|
run: dart run flutter_launcher_icons
|
||||||
|
|
||||||
@@ -379,8 +388,6 @@ jobs:
|
|||||||
### Installation
|
### Installation
|
||||||
**Android**: Enable "Install from unknown sources" and install the APK
|
**Android**: Enable "Install from unknown sources" and install the APK
|
||||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||||
|
|
||||||
  
|
|
||||||
FOOTER
|
FOOTER
|
||||||
|
|
||||||
echo "Release body:"
|
echo "Release body:"
|
||||||
@@ -390,7 +397,7 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ needs.get-version.outputs.version }}
|
tag_name: ${{ needs.get-version.outputs.version }}
|
||||||
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
|
name: SpotiFLAC-Mobile ${{ needs.get-version.outputs.version }}
|
||||||
body_path: /tmp/release_body.txt
|
body_path: /tmp/release_body.txt
|
||||||
files: ./release/*
|
files: ./release/*
|
||||||
draft: false
|
draft: false
|
||||||
@@ -556,7 +563,7 @@ jobs:
|
|||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
-F document=@"${ARM64_APK}" \
|
-F document=@"${ARM64_APK}" \
|
||||||
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
|
-F caption="SpotiFLAC Mobile ${VERSION} - arm64 (recommended)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Upload arm32 APK to channel
|
# Upload arm32 APK to channel
|
||||||
@@ -565,7 +572,7 @@ jobs:
|
|||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
-F document=@"${ARM32_APK}" \
|
-F document=@"${ARM32_APK}" \
|
||||||
-F caption="SpotiFLAC ${VERSION} - arm32"
|
-F caption="SpotiFLAC Mobile ${VERSION} - arm32"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Upload iOS IPA to channel
|
# Upload iOS IPA to channel
|
||||||
@@ -575,7 +582,7 @@ jobs:
|
|||||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||||
-F document=@"${IOS_IPA}" \
|
-F document=@"${IOS_IPA}" \
|
||||||
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
|
-F caption="SpotiFLAC Mobile ${VERSION} - iOS (unsigned, sideload required)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Telegram notification sent!"
|
echo "Telegram notification sent!"
|
||||||
|
|||||||
+4
-1
@@ -60,12 +60,15 @@ ios/Flutter/Flutter.framework/
|
|||||||
ios/Flutter/Flutter.podspec
|
ios/Flutter/Flutter.podspec
|
||||||
|
|
||||||
# Extension folder
|
# Extension folder
|
||||||
extension/
|
extension/*
|
||||||
|
extension/v2/
|
||||||
|
extension/v2/**
|
||||||
|
|
||||||
# Agent instructions
|
# Agent instructions
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|
||||||
# Temp/misc
|
# Temp/misc
|
||||||
|
.tmp/
|
||||||
nul
|
nul
|
||||||
NUL
|
NUL
|
||||||
network_requests.txt
|
network_requests.txt
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ Extensions let the community add new music sources and features without waiting
|
|||||||
## Related Projects
|
## Related Projects
|
||||||
|
|
||||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music available for Windows, macOS & Linux.
|
Download music in true lossless FLAC from extension-provided sources on Windows, macOS & Linux.
|
||||||
|
|
||||||
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
### [SpotiFLAC (Python Module)](https://github.com/ShuShuzinhuu/SpotiFLAC-Module-Version)
|
||||||
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
|
Python library for SpotiFLAC integration, maintained by [@ShuShuzinhuu](https://github.com/ShuShuzinhuu).
|
||||||
@@ -80,7 +80,7 @@ Starting from version 3.8.0, SpotiFLAC uses a decentralized extension repository
|
|||||||
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
<summary><b>Why is my download failing with "Song not found"?</b></summary>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
The track may not be available on the streaming services. Try enabling more providers under **Settings > Download > Provider Priority**, or install additional extensions like Amazon Music from the Store.
|
The track may not be available from your enabled providers. Try enabling more providers under **Settings > Extensions > Provider Priority**, or install additional download extensions from the Store.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -88,10 +88,7 @@ The track may not be available on the streaming services. Try enabling more prov
|
|||||||
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
<summary><b>Why are some tracks downloading in lower quality?</b></summary>
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
|
Quality depends on what's available from the source and the installed download extension. Check each extension's quality options and service notes in the app.
|
||||||
- **Tidal** up to 24-bit/192kHz
|
|
||||||
- **Qobuz** up to 24-bit/192kHz
|
|
||||||
- **Deezer** up to 16-bit/44.1kHz
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
riverpod_lint: 3.1.4-dev.3
|
||||||
|
|
||||||
analyzer:
|
analyzer:
|
||||||
exclude:
|
exclude:
|
||||||
- build/**
|
- build/**
|
||||||
@@ -19,9 +22,6 @@ analyzer:
|
|||||||
strict-casts: true
|
strict-casts: true
|
||||||
strict-inference: true
|
strict-inference: true
|
||||||
strict-raw-types: true
|
strict-raw-types: true
|
||||||
plugins:
|
|
||||||
- custom_lint
|
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
# The lint rules applied to this project can be customized in the
|
# The lint rules applied to this project can be customized in the
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
@@ -44,9 +44,5 @@ linter:
|
|||||||
cancel_subscriptions: true
|
cancel_subscriptions: true
|
||||||
close_sinks: true
|
close_sinks: true
|
||||||
|
|
||||||
custom_lint:
|
|
||||||
rules:
|
|
||||||
- avoid_public_notifier_properties
|
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ if (keystorePropertiesFile.exists()) {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.zarz.spotiflac"
|
namespace = "com.zarz.spotiflac"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = 37
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
@@ -26,13 +26,13 @@ android {
|
|||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_25
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_25
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.zarz.spotiflac"
|
applicationId = "com.zarz.spotiflac"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = 36
|
targetSdk = 37
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
multiDexEnabled = true
|
multiDexEnabled = true
|
||||||
@@ -62,6 +62,8 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
getByName("debug") {
|
getByName("debug") {
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
ndk {
|
ndk {
|
||||||
debugSymbolLevel = "FULL"
|
debugSymbolLevel = "FULL"
|
||||||
}
|
}
|
||||||
@@ -120,8 +122,8 @@ dependencies {
|
|||||||
// Include all AAR and JAR files from libs folder
|
// Include all AAR and JAR files from libs folder
|
||||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||||
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0-beta02")
|
||||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||||
implementation("androidx.activity:activity-ktx:1.13.0")
|
implementation("androidx.activity:activity-ktx:1.13.0")
|
||||||
implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0")
|
implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0")
|
||||||
|
|||||||
@@ -100,6 +100,12 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="spotiflac" android:host="spotify-callback" />
|
<data android:scheme="spotiflac" android:host="spotify-callback" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="spotiflac" android:host="session-grant" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- Download Service -->
|
<!-- Download Service -->
|
||||||
@@ -108,6 +114,23 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name="com.ryanheise.audioservice.AudioService"
|
||||||
|
android:foregroundServiceType="mediaPlayback"
|
||||||
|
android:exported="true"
|
||||||
|
android:enabled="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
<receiver
|
||||||
|
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<!-- flutter_local_notifications receivers -->
|
<!-- flutter_local_notifications receivers -->
|
||||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||||
@@ -124,6 +147,10 @@
|
|||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.car.application"
|
||||||
|
android:resource="@xml/automotive_app_desc" />
|
||||||
|
|
||||||
<!-- FileProvider for APK installation -->
|
<!-- FileProvider for APK installation -->
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|||||||
@@ -690,7 +690,8 @@ class DownloadService : Service() {
|
|||||||
request.itemId,
|
request.itemId,
|
||||||
request.requestJson,
|
request.requestJson,
|
||||||
request.itemJson,
|
request.itemJson,
|
||||||
result
|
result,
|
||||||
|
settingsJson
|
||||||
) {
|
) {
|
||||||
nativeWorkerCancelRequested ||
|
nativeWorkerCancelRequested ||
|
||||||
nativeWorkerPaused ||
|
nativeWorkerPaused ||
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.zarz.spotiflac
|
package com.zarz.spotiflac
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -17,6 +18,7 @@ import io.flutter.embedding.engine.FlutterEngine
|
|||||||
import io.flutter.embedding.engine.FlutterShellArgs
|
import io.flutter.embedding.engine.FlutterShellArgs
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import com.ryanheise.audioservice.AudioServicePlugin
|
||||||
import gobackend.Gobackend
|
import gobackend.Gobackend
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -36,6 +38,10 @@ import java.security.MessageDigest
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class MainActivity: FlutterFragmentActivity() {
|
class MainActivity: FlutterFragmentActivity() {
|
||||||
|
override fun provideFlutterEngine(context: Context): FlutterEngine {
|
||||||
|
return AudioServicePlugin.getFlutterEngine(context)
|
||||||
|
}
|
||||||
|
|
||||||
private val CHANNEL = "com.zarz.spotiflac/backend"
|
private val CHANNEL = "com.zarz.spotiflac/backend"
|
||||||
private val DOWNLOAD_PROGRESS_STREAM_CHANNEL =
|
private val DOWNLOAD_PROGRESS_STREAM_CHANNEL =
|
||||||
"com.zarz.spotiflac/download_progress_stream"
|
"com.zarz.spotiflac/download_progress_stream"
|
||||||
@@ -47,6 +53,8 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
private val LARGE_JSON_RESULT_FILE_KEY = "__json_file"
|
private val LARGE_JSON_RESULT_FILE_KEY = "__json_file"
|
||||||
private val LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024
|
private val LARGE_JSON_RESULT_FILE_THRESHOLD_BYTES = 256 * 1024
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
private var backendChannel: MethodChannel? = null
|
||||||
|
private val pendingSessionGrantEvents = mutableListOf<Map<String, Any>>()
|
||||||
private var pendingSafTreeResult: MethodChannel.Result? = null
|
private var pendingSafTreeResult: MethodChannel.Result? = null
|
||||||
private val safScanLock = Any()
|
private val safScanLock = Any()
|
||||||
private val safDirLock = Any()
|
private val safDirLock = Any()
|
||||||
@@ -148,8 +156,15 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
"mali-t7",
|
"mali-t7",
|
||||||
"powervr sgx",
|
"powervr sgx",
|
||||||
"powervr ge8320",
|
"powervr ge8320",
|
||||||
|
"vivante",
|
||||||
"gc1000",
|
"gc1000",
|
||||||
"gc2000",
|
"gc2000",
|
||||||
|
"gc4000",
|
||||||
|
"gc5000",
|
||||||
|
"gc7000",
|
||||||
|
"gc8000",
|
||||||
|
"gc820",
|
||||||
|
"gc880",
|
||||||
)
|
)
|
||||||
|
|
||||||
private val PROBLEMATIC_CHIPSETS = listOf(
|
private val PROBLEMATIC_CHIPSETS = listOf(
|
||||||
@@ -163,6 +178,15 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
"apq8084",
|
"apq8084",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Sony Walkman / audio players report MANUFACTURER "SonyAudio" (distinct
|
||||||
|
// from Xperia phones, which use "Sony"). They ship legacy Vivante GPUs
|
||||||
|
// whose drivers crash in glLinkProgram with Impeller shaders, and the GL
|
||||||
|
// renderer string is unavailable when shell args are built, so match on
|
||||||
|
// the manufacturer instead.
|
||||||
|
private val PROBLEMATIC_MANUFACTURERS = listOf(
|
||||||
|
"sonyaudio",
|
||||||
|
)
|
||||||
|
|
||||||
private val PROBLEMATIC_MODELS = listOf(
|
private val PROBLEMATIC_MODELS = listOf(
|
||||||
"sm-t220",
|
"sm-t220",
|
||||||
"sm-t225",
|
"sm-t225",
|
||||||
@@ -173,6 +197,14 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
val board = Build.BOARD.lowercase(Locale.ROOT)
|
val board = Build.BOARD.lowercase(Locale.ROOT)
|
||||||
val model = Build.MODEL.lowercase(Locale.ROOT)
|
val model = Build.MODEL.lowercase(Locale.ROOT)
|
||||||
val device = Build.DEVICE.lowercase(Locale.ROOT)
|
val device = Build.DEVICE.lowercase(Locale.ROOT)
|
||||||
|
val manufacturer = Build.MANUFACTURER.lowercase(Locale.ROOT)
|
||||||
|
|
||||||
|
for (problematicManufacturer in PROBLEMATIC_MANUFACTURERS) {
|
||||||
|
if (manufacturer.contains(problematicManufacturer)) {
|
||||||
|
android.util.Log.i("SpotiFLAC", "Matched problematic manufacturer: $problematicManufacturer")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (problematicModel in PROBLEMATIC_MODELS) {
|
for (problematicModel in PROBLEMATIC_MODELS) {
|
||||||
if (model.contains(problematicModel) || device.contains(problematicModel)) {
|
if (model.contains(problematicModel) || device.contains(problematicModel)) {
|
||||||
@@ -307,6 +339,8 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
".mp3" -> "audio/mpeg"
|
".mp3" -> "audio/mpeg"
|
||||||
".opus" -> "audio/ogg"
|
".opus" -> "audio/ogg"
|
||||||
".flac" -> "audio/flac"
|
".flac" -> "audio/flac"
|
||||||
|
".wav" -> "audio/wav"
|
||||||
|
".aiff", ".aif", ".aifc" -> "audio/aiff"
|
||||||
".lrc" -> "application/octet-stream"
|
".lrc" -> "application/octet-stream"
|
||||||
else -> "application/octet-stream"
|
else -> "application/octet-stream"
|
||||||
}
|
}
|
||||||
@@ -791,6 +825,8 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
"audio/mpeg" -> ".mp3"
|
"audio/mpeg" -> ".mp3"
|
||||||
"audio/ogg" -> ".opus"
|
"audio/ogg" -> ".opus"
|
||||||
"audio/flac" -> ".flac"
|
"audio/flac" -> ".flac"
|
||||||
|
"audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave" -> ".wav"
|
||||||
|
"audio/aiff", "audio/x-aiff" -> ".aiff"
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1037,6 +1073,48 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a ".lrc" sidecar next to a SAF audio document. The sidecar reuses
|
||||||
|
* the audio file's base name (e.g. "Song.flac" -> "Song.lrc") and is created
|
||||||
|
* in the same parent directory. Used by re-enrich when the user's lyrics
|
||||||
|
* mode requests an external/both sidecar. Best-effort: failures are logged
|
||||||
|
* and swallowed so they never abort the metadata enrichment itself.
|
||||||
|
*/
|
||||||
|
private fun writeSafSidecarLrc(audioUri: Uri, lrcContent: String): Boolean {
|
||||||
|
if (lrcContent.isBlank()) return false
|
||||||
|
try {
|
||||||
|
val parent = safParentDir(audioUri) ?: run {
|
||||||
|
android.util.Log.w("SpotiFLAC", "LRC sidecar: no SAF parent dir")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val audioName = try {
|
||||||
|
DocumentFile.fromSingleUri(this, audioUri)?.name
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
} ?: return false
|
||||||
|
val baseName = audioName.substringBeforeLast('.', audioName)
|
||||||
|
val lrcName = "$baseName.lrc"
|
||||||
|
|
||||||
|
val target = createOrReuseDocumentFile(
|
||||||
|
parent,
|
||||||
|
"application/octet-stream",
|
||||||
|
lrcName
|
||||||
|
) ?: run {
|
||||||
|
android.util.Log.w("SpotiFLAC", "LRC sidecar: failed to create $lrcName")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
contentResolver.openOutputStream(target.uri, "wt")?.use { output ->
|
||||||
|
output.write(lrcContent.toByteArray(Charsets.UTF_8))
|
||||||
|
} ?: return false
|
||||||
|
android.util.Log.d("SpotiFLAC", "LRC sidecar written: $lrcName")
|
||||||
|
return true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("SpotiFLAC", "LRC sidecar write failed: ${e.message}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the audio filename referenced by a CUE sheet file.
|
* Extract the audio filename referenced by a CUE sheet file.
|
||||||
* Reads the FILE "name" TYPE line from the .cue text.
|
* Reads the FILE "name" TYPE line from the .cue text.
|
||||||
@@ -1071,6 +1149,16 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac"
|
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Audio file extensions that the local library scanner accepts. Must stay in
|
||||||
|
// sync with supportedAudioFormats in go_backend/library_scan.go so that every
|
||||||
|
// format the Go engine can read (FLAC, M4A/MP4/AAC, MP3, Opus/OGG, APE/WV/MPC,
|
||||||
|
// WAV, AIFF) is also enumerated here during the SAF folder walk. (.cue is
|
||||||
|
// handled separately.)
|
||||||
|
private val libraryScanAudioExtensions = setOf(
|
||||||
|
".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg",
|
||||||
|
".ape", ".wv", ".mpc", ".wav", ".aiff", ".aif"
|
||||||
|
)
|
||||||
|
|
||||||
private fun getSafChildFileLookup(
|
private fun getSafChildFileLookup(
|
||||||
dir: DocumentFile,
|
dir: DocumentFile,
|
||||||
cache: MutableMap<String, Map<String, DocumentFile>>,
|
cache: MutableMap<String, Map<String, DocumentFile>>,
|
||||||
@@ -1140,7 +1228,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
it.currentFile = "Scanning folders..."
|
it.currentFile = "Scanning folders..."
|
||||||
}
|
}
|
||||||
|
|
||||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
|
val supportedAudioExt = libraryScanAudioExtensions
|
||||||
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
|
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
|
||||||
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||||
val visitedDirUris = mutableSetOf<String>()
|
val visitedDirUris = mutableSetOf<String>()
|
||||||
@@ -1440,7 +1528,7 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
it.currentFile = "Scanning folders..."
|
it.currentFile = "Scanning folders..."
|
||||||
}
|
}
|
||||||
|
|
||||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
|
val supportedAudioExt = libraryScanAudioExtensions
|
||||||
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
|
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
|
||||||
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
|
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
|
||||||
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||||
@@ -1993,14 +2081,22 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
val host = (uri.host ?: "").lowercase(Locale.US)
|
val host = (uri.host ?: "").lowercase(Locale.US)
|
||||||
val path = (uri.path ?: "").lowercase(Locale.US)
|
val path = (uri.path ?: "").lowercase(Locale.US)
|
||||||
|
val isSessionGrant = host == "session-grant"
|
||||||
val isCallback =
|
val isCallback =
|
||||||
host == "callback" ||
|
isSessionGrant ||
|
||||||
|
host == "callback" ||
|
||||||
host == "spotify-callback" ||
|
host == "spotify-callback" ||
|
||||||
path.contains("callback")
|
path.contains("callback")
|
||||||
if (!isCallback) {
|
if (!isCallback) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val code = uri.getQueryParameter("code")?.trim().orEmpty()
|
val code = (
|
||||||
|
if (isSessionGrant) {
|
||||||
|
uri.getQueryParameter("grant") ?: uri.getQueryParameter("code")
|
||||||
|
} else {
|
||||||
|
uri.getQueryParameter("code")
|
||||||
|
}
|
||||||
|
)?.trim().orEmpty()
|
||||||
if (code.isEmpty()) {
|
if (code.isEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2012,15 +2108,43 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
intent.data = null
|
intent.data = null
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Gobackend.setExtensionAuthCodeByID(extId, code)
|
val json = if (isSessionGrant) {
|
||||||
val json = Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin")
|
Gobackend.setExtensionSessionGrantByID(extId, code)
|
||||||
android.util.Log.i("SpotiFLAC", "Extension OAuth complete for $extId: $json")
|
Gobackend.invokeExtensionActionJSON(extId, "completeGrant")
|
||||||
|
} else {
|
||||||
|
Gobackend.setExtensionAuthCodeByID(extId, code)
|
||||||
|
Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin")
|
||||||
|
}
|
||||||
|
android.util.Log.i("SpotiFLAC", "Extension callback complete for $extId: $json")
|
||||||
|
if (isSessionGrant) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
notifySessionGrantCompleted(extId, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.w("SpotiFLAC", "Extension OAuth failed: ${e.message}")
|
android.util.Log.w("SpotiFLAC", "Extension callback failed: ${e.message}")
|
||||||
|
if (isSessionGrant) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
notifySessionGrantCompleted(extId, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun notifySessionGrantCompleted(extensionId: String, success: Boolean) {
|
||||||
|
val payload = mapOf(
|
||||||
|
"extension_id" to extensionId,
|
||||||
|
"success" to success,
|
||||||
|
)
|
||||||
|
val channel = backendChannel
|
||||||
|
if (channel == null) {
|
||||||
|
pendingSessionGrantEvents.add(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
channel.invokeMethod("extensionSessionGrantCompleted", payload)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
try {
|
try {
|
||||||
Gobackend.cleanupExtensions()
|
Gobackend.cleanupExtensions()
|
||||||
@@ -2084,7 +2208,17 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result ->
|
val channel = MethodChannel(messenger, CHANNEL)
|
||||||
|
backendChannel = channel
|
||||||
|
if (pendingSessionGrantEvents.isNotEmpty()) {
|
||||||
|
val events = pendingSessionGrantEvents.toList()
|
||||||
|
pendingSessionGrantEvents.clear()
|
||||||
|
for (event in events) {
|
||||||
|
channel.invokeMethod("extensionSessionGrantCompleted", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.setMethodCallHandler { call, result ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
@@ -2169,6 +2303,13 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"setAllowPrivateNetwork" -> {
|
||||||
|
val allowed = call.argument<Boolean>("allowed") ?: false
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.setAllowPrivateNetwork(allowed)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
"checkDuplicate" -> {
|
"checkDuplicate" -> {
|
||||||
val outputDir = call.argument<String>("output_dir") ?: ""
|
val outputDir = call.argument<String>("output_dir") ?: ""
|
||||||
val isrc = call.argument<String>("isrc") ?: ""
|
val isrc = call.argument<String>("isrc") ?: ""
|
||||||
@@ -2587,6 +2728,46 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"writeM4AFreeformTags" -> {
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val metadataJson = call.argument<String>("metadata_json") ?: "{}"
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
Gobackend.writeM4AFreeformTags(filePath, metadataJson)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SpotiFLAC", "writeM4AFreeformTags failed: ${e.message}", e)
|
||||||
|
"""{"error":"${e.message?.replace("\"", "'")}"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"ensureAC4Config" -> {
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val sourcePath = call.argument<String>("source_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
Gobackend.ensureAC4Config(filePath, sourcePath)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SpotiFLAC", "ensureAC4Config failed: ${e.message}", e)
|
||||||
|
"""{"error":"${e.message?.replace("\"", "'")}"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
|
"writeAC4Metadata" -> {
|
||||||
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val metadataJson = call.argument<String>("metadata_json") ?: "{}"
|
||||||
|
val coverPath = call.argument<String>("cover_path") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
Gobackend.writeAC4Metadata(filePath, metadataJson, coverPath)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SpotiFLAC", "writeAC4Metadata failed: ${e.message}", e)
|
||||||
|
"""{"error":"${e.message?.replace("\"", "'")}"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"writeTempToSaf" -> {
|
"writeTempToSaf" -> {
|
||||||
val tempPath = call.argument<String>("temp_path") ?: ""
|
val tempPath = call.argument<String>("temp_path") ?: ""
|
||||||
val safUri = call.argument<String>("saf_uri") ?: ""
|
val safUri = call.argument<String>("saf_uri") ?: ""
|
||||||
@@ -2604,6 +2785,23 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"writeSafSidecarLrc" -> {
|
||||||
|
val safUri = call.argument<String>("saf_uri") ?: ""
|
||||||
|
val lyrics = call.argument<String>("lyrics") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val uri = Uri.parse(safUri)
|
||||||
|
if (writeSafSidecarLrc(uri, lyrics)) {
|
||||||
|
"""{"success":true}"""
|
||||||
|
} else {
|
||||||
|
"""{"success":false,"error":"Failed to write LRC sidecar"}"""
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"downloadCoverToFile" -> {
|
"downloadCoverToFile" -> {
|
||||||
val coverUrl = call.argument<String>("cover_url") ?: ""
|
val coverUrl = call.argument<String>("cover_url") ?: ""
|
||||||
val outputPath = call.argument<String>("output_path") ?: ""
|
val outputPath = call.argument<String>("output_path") ?: ""
|
||||||
@@ -2761,6 +2959,9 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
if (!writeUriFromPath(uri, tempPath)) {
|
if (!writeUriFromPath(uri, tempPath)) {
|
||||||
return@withContext """{"error":"Failed to write enriched metadata back to SAF file"}"""
|
return@withContext """{"error":"Failed to write enriched metadata back to SAF file"}"""
|
||||||
}
|
}
|
||||||
|
if (obj.optBoolean("write_external_lrc", false)) {
|
||||||
|
writeSafSidecarLrc(uri, obj.optString("lyrics", ""))
|
||||||
|
}
|
||||||
raw
|
raw
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
try { File(tempPath).delete() } catch (_: Exception) {}
|
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||||
@@ -3095,6 +3296,17 @@ class MainActivity: FlutterFragmentActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"findCollectionAcrossExtensions" -> {
|
||||||
|
val requestJson = call.arguments as? String ?: "{}"
|
||||||
|
val response: String = withContext(Dispatchers.IO) {
|
||||||
|
val method = Gobackend::class.java.getMethod(
|
||||||
|
"findCollectionAcrossExtensionsJSON",
|
||||||
|
String::class.java
|
||||||
|
)
|
||||||
|
method.invoke(null, requestJson) as? String ?: "[]"
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"enrichTrackWithExtension" -> {
|
"enrichTrackWithExtension" -> {
|
||||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
val trackJson = call.argument<String>("track") ?: "{}"
|
val trackJson = call.argument<String>("track") ?: "{}"
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ object NativeDownloadFinalizer {
|
|||||||
requestJson: String,
|
requestJson: String,
|
||||||
itemJson: String,
|
itemJson: String,
|
||||||
result: JSONObject,
|
result: JSONObject,
|
||||||
|
settingsJson: String = "{}",
|
||||||
shouldCancel: () -> Boolean = { false },
|
shouldCancel: () -> Boolean = { false },
|
||||||
): JSONObject {
|
): JSONObject {
|
||||||
if (!result.optBoolean("success", false)) return result
|
if (!result.optBoolean("success", false)) return result
|
||||||
@@ -217,15 +218,20 @@ object NativeDownloadFinalizer {
|
|||||||
refreshFinalAudioQualityMetadata(context, result, state)
|
refreshFinalAudioQualityMetadata(context, result, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
val history = buildHistoryRow(effectiveInput, state)
|
val saveDownloadHistory = parseObject(settingsJson)
|
||||||
upsertHistory(context, history)
|
.optBoolean("save_download_history", true)
|
||||||
|
val history = if (saveDownloadHistory) {
|
||||||
|
buildHistoryRow(effectiveInput, state).also { upsertHistory(context, it) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
result.put("file_path", state.filePath)
|
result.put("file_path", state.filePath)
|
||||||
if (state.fileName.isNotBlank()) result.put("file_name", state.fileName)
|
if (state.fileName.isNotBlank()) result.put("file_name", state.fileName)
|
||||||
if (state.quality.isNotBlank()) result.put("quality", state.quality)
|
if (state.quality.isNotBlank()) result.put("quality", state.quality)
|
||||||
result.put("native_finalized", true)
|
result.put("native_finalized", true)
|
||||||
result.put("history_written", true)
|
result.put("history_written", history != null)
|
||||||
result.put("history_item", historyToJson(history))
|
if (history != null) result.put("history_item", historyToJson(history))
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
cleanupFailedFinalizationOutput(context, result, initialPath, state.filePath)
|
cleanupFailedFinalizationOutput(context, result, initialPath, state.filePath)
|
||||||
result.put("success", false)
|
result.put("success", false)
|
||||||
@@ -328,7 +334,6 @@ object NativeDownloadFinalizer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun currentStatus(@Suppress("UNUSED_PARAMETER") status: String) {
|
private fun currentStatus(@Suppress("UNUSED_PARAMETER") status: String) {
|
||||||
// Kept as a narrow hook for future richer progress snapshots.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cleanupFailedFinalizationOutput(
|
private fun cleanupFailedFinalizationOutput(
|
||||||
@@ -416,16 +421,19 @@ object NativeDownloadFinalizer {
|
|||||||
try {
|
try {
|
||||||
for (candidate in decryptionKeyCandidates(key)) {
|
for (candidate in decryptionKeyCandidates(key)) {
|
||||||
checkCancelled(shouldCancel)
|
checkCancelled(shouldCancel)
|
||||||
val attempts = mutableListOf<Pair<String, Boolean>>()
|
val attempts = mutableListOf<Triple<String, Boolean, Boolean>>()
|
||||||
attempts.add(outputPath to (preferredExt == ".flac"))
|
attempts.add(Triple(outputPath, preferredExt == ".flac", false))
|
||||||
if (preferredExt == ".flac") {
|
if (preferredExt == ".flac") {
|
||||||
attempts.add(buildOutputPath(localInput, ".m4a") to false)
|
attempts.add(Triple(buildOutputPath(localInput, ".m4a"), false, false))
|
||||||
}
|
}
|
||||||
if (preferredExt == ".flac" || preferredExt == ".m4a") {
|
if (preferredExt == ".flac" || preferredExt == ".m4a") {
|
||||||
attempts.add(buildOutputPath(localInput, ".mp4") to false)
|
attempts.add(Triple(buildOutputPath(localInput, ".mp4"), false, false))
|
||||||
}
|
}
|
||||||
|
// MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4):
|
||||||
|
// keeps the .mp4 filename but stores the codec params.
|
||||||
|
attempts.add(Triple(buildOutputPath(localInput, ".mp4"), false, true))
|
||||||
|
|
||||||
for ((candidateOutput, mapAudioOnly) in attempts) {
|
for ((candidateOutput, mapAudioOnly, forceMov) in attempts) {
|
||||||
try {
|
try {
|
||||||
val audioMap = if (mapAudioOnly) "-map 0:a " else ""
|
val audioMap = if (mapAudioOnly) "-map 0:a " else ""
|
||||||
// Force the flac muxer when the target extension is
|
// Force the flac muxer when the target extension is
|
||||||
@@ -433,7 +441,11 @@ object NativeDownloadFinalizer {
|
|||||||
// stream layout, producing FLAC-in-MP4 under a .flac
|
// stream layout, producing FLAC-in-MP4 under a .flac
|
||||||
// filename which downstream native FLAC tag writers
|
// filename which downstream native FLAC tag writers
|
||||||
// cannot read.
|
// cannot read.
|
||||||
val muxerOverride = if (candidateOutput.lowercase(Locale.ROOT).endsWith(".flac")) "-f flac " else ""
|
val muxerOverride = when {
|
||||||
|
forceMov -> "-f mov "
|
||||||
|
candidateOutput.lowercase(Locale.ROOT).endsWith(".flac") -> "-f flac "
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${muxerOverride}${q(candidateOutput)} -y"
|
val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${muxerOverride}${q(candidateOutput)} -y"
|
||||||
val result = runFFmpeg(command, shouldCancel)
|
val result = runFFmpeg(command, shouldCancel)
|
||||||
lastOutput = result.second
|
lastOutput = result.second
|
||||||
@@ -1081,10 +1093,11 @@ object NativeDownloadFinalizer {
|
|||||||
val genre = resultString(input, "genre").ifBlank { requestString(input, "genre") }
|
val genre = resultString(input, "genre").ifBlank { requestString(input, "genre") }
|
||||||
val label = resultString(input, "label").ifBlank { requestString(input, "label") }
|
val label = resultString(input, "label").ifBlank { requestString(input, "label") }
|
||||||
val copyright = resultString(input, "copyright").ifBlank { requestString(input, "copyright") }
|
val copyright = resultString(input, "copyright").ifBlank { requestString(input, "copyright") }
|
||||||
val lyrics = resolveLyricsLrc(input)
|
val lyricsMode = input.request.optString("lyrics_mode", "embed")
|
||||||
val shouldEmbedLyrics = input.request.optBoolean("embed_lyrics", false) &&
|
val shouldResolveLyrics = input.request.optBoolean("embed_lyrics", false) &&
|
||||||
(input.request.optString("lyrics_mode", "embed") == "embed" ||
|
(lyricsMode == "embed" || lyricsMode == "both")
|
||||||
input.request.optString("lyrics_mode", "embed") == "both") &&
|
val lyrics = if (shouldResolveLyrics) resolveLyricsLrc(input) else ""
|
||||||
|
val shouldEmbedLyrics = shouldResolveLyrics &&
|
||||||
lyrics.isNotBlank() &&
|
lyrics.isNotBlank() &&
|
||||||
lyrics != "[instrumental:true]"
|
lyrics != "[instrumental:true]"
|
||||||
if (format == "flac") {
|
if (format == "flac") {
|
||||||
@@ -1152,18 +1165,28 @@ object NativeDownloadFinalizer {
|
|||||||
val mp3Flags = if (format == "mp3") "-id3v2_version 3 " else ""
|
val mp3Flags = if (format == "mp3") "-id3v2_version 3 " else ""
|
||||||
var adoptedTemp = false
|
var adoptedTemp = false
|
||||||
var originalDeleted = false
|
var originalDeleted = false
|
||||||
try {
|
|
||||||
val command = if (isM4a && coverFile != null) {
|
fun buildEmbedCommand(forceMov: Boolean): String {
|
||||||
|
return if (isM4a && coverFile != null) {
|
||||||
"-v error -hide_banner -i ${q(path)} -i ${q(coverFile.absolutePath)} " +
|
"-v error -hide_banner -i ${q(path)} -i ${q(coverFile.absolutePath)} " +
|
||||||
"-map 0:a -c:a copy -map_metadata 0 -map 1:v -c:v copy " +
|
"-map 0:a -c:a copy -map_metadata 0 -map 1:v -c:v copy " +
|
||||||
"-disposition:v:0 attached_pic " +
|
"-disposition:v:0 attached_pic " +
|
||||||
"-metadata:s:v ${q("title=Album cover")} " +
|
"-metadata:s:v ${q("title=Album cover")} " +
|
||||||
"-metadata:s:v ${q("comment=Cover (front)")} " +
|
"-metadata:s:v ${q("comment=Cover (front)")} " +
|
||||||
"$metadataArgs -f mp4 ${q(temp.absolutePath)} -y"
|
"$metadataArgs -f ${if (forceMov) "mov" else "mp4"} ${q(temp.absolutePath)} -y"
|
||||||
} else {
|
} else {
|
||||||
"-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags${q(temp.absolutePath)} -y"
|
val movFlag = if (forceMov) "-f mov " else ""
|
||||||
|
"-v error -hide_banner -i ${q(path)} -map 0 -c copy -map_metadata 0 $metadataArgs $mp3Flags$movFlag${q(temp.absolutePath)} -y"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var result = runFFmpeg(buildEmbedCommand(false))
|
||||||
|
// MOV muxer fallback for codecs the MP4 muxer rejects (e.g. AC-4).
|
||||||
|
if (!result.first && (isM4a || ext.equals(".mp4", ignoreCase = true))) {
|
||||||
|
temp.delete()
|
||||||
|
result = runFFmpeg(buildEmbedCommand(true))
|
||||||
}
|
}
|
||||||
val result = runFFmpeg(command)
|
|
||||||
if (result.first && temp.exists()) {
|
if (result.first && temp.exists()) {
|
||||||
if (inputFile.delete()) {
|
if (inputFile.delete()) {
|
||||||
originalDeleted = true
|
originalDeleted = true
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<automotiveApp>
|
||||||
|
<uses name="media" />
|
||||||
|
</automotiveApp>
|
||||||
@@ -11,8 +11,8 @@ subprojects {
|
|||||||
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
project.extensions.configure<com.android.build.gradle.BaseExtension>("android") {
|
||||||
compileOptions {
|
compileOptions {
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_25
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_25
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable multidex for all subprojects
|
// Enable multidex for all subprojects
|
||||||
@@ -27,7 +27,7 @@ subprojects {
|
|||||||
|
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||||
|
android.builtInKotlin=false
|
||||||
|
# This newDsl flag was added automatically by Flutter migrator
|
||||||
|
android.newDsl=false
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.13.2" apply false
|
id("com.android.application") version "9.2.1" apply false
|
||||||
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
|
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
"name": "SpotiFLAC Mobile",
|
"name": "SpotiFLAC Mobile",
|
||||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||||
"developerName": "zarzet",
|
"developerName": "zarzet",
|
||||||
"version": "4.5.5",
|
"version": "4.7.1",
|
||||||
"versionDate": "2026-05-14",
|
"versionDate": "2026-07-01",
|
||||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.5/SpotiFLAC-v4.5.5-ios-unsigned.ipa",
|
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.7.1/SpotiFLAC-v4.7.1-ios-unsigned.ipa",
|
||||||
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
"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",
|
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||||
"size": 37191956
|
"size": 37455821
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-5
@@ -3,9 +3,11 @@ files:
|
|||||||
translation: /lib/l10n/arb/app_%locale%.arb
|
translation: /lib/l10n/arb/app_%locale%.arb
|
||||||
languages_mapping:
|
languages_mapping:
|
||||||
locale:
|
locale:
|
||||||
# Short codes for single-variant languages
|
# Keys MUST be the project's Crowdin language ids; values are the
|
||||||
|
# %locale% suffix used in app_%locale%.arb (underscores so Flutter
|
||||||
|
# gen-l10n parses them — hyphenated filenames break gen-l10n).
|
||||||
|
ar: ar
|
||||||
de: de
|
de: de
|
||||||
es: es
|
|
||||||
es-ES: es_ES
|
es-ES: es_ES
|
||||||
fr: fr
|
fr: fr
|
||||||
hi: hi
|
hi: hi
|
||||||
@@ -13,12 +15,9 @@ files:
|
|||||||
ja: ja
|
ja: ja
|
||||||
ko: ko
|
ko: ko
|
||||||
nl: nl
|
nl: nl
|
||||||
pt: pt
|
|
||||||
pt-PT: pt_PT
|
pt-PT: pt_PT
|
||||||
ru: ru
|
ru: ru
|
||||||
tr: tr
|
tr: tr
|
||||||
uk: uk
|
uk: uk
|
||||||
zh: zh
|
|
||||||
# Full codes for Chinese variants
|
|
||||||
zh-CN: zh_CN
|
zh-CN: zh_CN
|
||||||
zh-TW: zh_TW
|
zh-TW: zh_TW
|
||||||
|
|||||||
@@ -0,0 +1,322 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mp4Box is a minimal ISO-BMFF / QuickTime box view over an in-memory buffer.
|
||||||
|
type mp4Box struct {
|
||||||
|
offset int64
|
||||||
|
size int64
|
||||||
|
hdr int64
|
||||||
|
typ string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b mp4Box) body() int64 { return b.offset + b.hdr }
|
||||||
|
func (b mp4Box) end() int64 { return b.offset + b.size }
|
||||||
|
|
||||||
|
func readMP4Box(data []byte, pos int64) (mp4Box, bool) {
|
||||||
|
n := int64(len(data))
|
||||||
|
if pos < 0 || pos+8 > n {
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
size := int64(binary.BigEndian.Uint32(data[pos : pos+4]))
|
||||||
|
typ := string(data[pos+4 : pos+8])
|
||||||
|
hdr := int64(8)
|
||||||
|
if size == 1 {
|
||||||
|
if pos+16 > n {
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
size = int64(binary.BigEndian.Uint64(data[pos+8 : pos+16]))
|
||||||
|
hdr = 16
|
||||||
|
} else if size == 0 {
|
||||||
|
size = n - pos
|
||||||
|
}
|
||||||
|
if size < hdr || pos+size > n {
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
return mp4Box{offset: pos, size: size, hdr: hdr, typ: typ}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func findChildMP4(data []byte, start, end int64, typ string) (mp4Box, bool) {
|
||||||
|
pos := start
|
||||||
|
for pos+8 <= end {
|
||||||
|
b, ok := readMP4Box(data, pos)
|
||||||
|
if !ok {
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
if b.typ == typ {
|
||||||
|
return b, true
|
||||||
|
}
|
||||||
|
pos = b.end()
|
||||||
|
}
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func eachChildMP4(data []byte, start, end int64, typ string, fn func(mp4Box) bool) {
|
||||||
|
pos := start
|
||||||
|
for pos+8 <= end {
|
||||||
|
b, ok := readMP4Box(data, pos)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b.typ == typ && !fn(b) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pos = b.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findBoxBySignature scans [start,end) for a box of the given type, matching the
|
||||||
|
// 4-byte type tag and validating the preceding size field. Used to locate dac4
|
||||||
|
// which may be nested inside an encrypted (enca) sample entry.
|
||||||
|
func findBoxBySignature(data []byte, start, end int64, typ string) (mp4Box, bool) {
|
||||||
|
if len(typ) != 4 {
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
for i := start; i+8 <= end; i++ {
|
||||||
|
if data[i+4] == typ[0] && data[i+5] == typ[1] && data[i+6] == typ[2] && data[i+7] == typ[3] {
|
||||||
|
if b, ok := readMP4Box(data, i); ok && b.typ == typ {
|
||||||
|
return b, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mp4Box{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// audioSampleEntryHeaderLen returns the byte length of the fixed audio sample
|
||||||
|
// entry header (from the box body start) before child boxes begin. ok is false
|
||||||
|
// for malformed/truncated entries whose declared header is not fully present.
|
||||||
|
func audioSampleEntryHeaderLen(data []byte, entry mp4Box) (hdrLen int64, ok bool) {
|
||||||
|
// 6 bytes reserved + 2 bytes data_reference_index, then the audio fields.
|
||||||
|
base := entry.body()
|
||||||
|
if base+10 > entry.end() {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
version := binary.BigEndian.Uint16(data[base+8 : base+10])
|
||||||
|
hdrLen = 8 + 20
|
||||||
|
switch version {
|
||||||
|
case 1:
|
||||||
|
hdrLen += 16
|
||||||
|
case 2:
|
||||||
|
hdrLen += 36
|
||||||
|
}
|
||||||
|
if base+hdrLen > entry.end() {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return hdrLen, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type ac4Location struct {
|
||||||
|
chain []mp4Box // moov, trak, mdia, minf, stbl, stsd (ancestors to grow)
|
||||||
|
entry mp4Box // the ac-4 sample entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func locateAC4Entry(data []byte) (ac4Location, bool) {
|
||||||
|
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
|
||||||
|
if !ok {
|
||||||
|
return ac4Location{}, false
|
||||||
|
}
|
||||||
|
var found ac4Location
|
||||||
|
var ok2 bool
|
||||||
|
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
|
||||||
|
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
stsd, ok := findChildMP4(data, stbl.body(), stbl.end(), "stsd")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
entry, ok := findChildMP4(data, stsd.body()+8, stsd.end(), "ac-4")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
found = ac4Location{chain: []mp4Box{moov, trak, mdia, minf, stbl, stsd}, entry: entry}
|
||||||
|
ok2 = true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
return found, ok2
|
||||||
|
}
|
||||||
|
|
||||||
|
func growBoxSize(data []byte, b mp4Box, delta int64) {
|
||||||
|
if b.hdr == 16 {
|
||||||
|
binary.BigEndian.PutUint64(data[b.offset+8:b.offset+16], uint64(b.size+delta))
|
||||||
|
} else {
|
||||||
|
binary.BigEndian.PutUint32(data[b.offset:b.offset+4], uint32(b.size+delta))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shiftChunkOffsets adds delta to every stco/co64 entry that references a file
|
||||||
|
// offset at or beyond insertPos, keeping sample pointers valid after bytes are
|
||||||
|
// inserted into moov.
|
||||||
|
func shiftChunkOffsets(data []byte, moov mp4Box, insertPos, delta int64) {
|
||||||
|
eachChildMP4(data, moov.body(), moov.end(), "trak", func(trak mp4Box) bool {
|
||||||
|
mdia, ok := findChildMP4(data, trak.body(), trak.end(), "mdia")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
minf, ok := findChildMP4(data, mdia.body(), mdia.end(), "minf")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
stbl, ok := findChildMP4(data, minf.body(), minf.end(), "stbl")
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if stco, ok := findChildMP4(data, stbl.body(), stbl.end(), "stco"); ok {
|
||||||
|
base := stco.body() + 4
|
||||||
|
if base+4 <= stco.end() {
|
||||||
|
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
|
||||||
|
p := base + 4
|
||||||
|
for i := int64(0); i < count && p+4 <= stco.end(); i++ {
|
||||||
|
v := int64(binary.BigEndian.Uint32(data[p : p+4]))
|
||||||
|
if v >= insertPos {
|
||||||
|
binary.BigEndian.PutUint32(data[p:p+4], uint32(v+delta))
|
||||||
|
}
|
||||||
|
p += 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if co64, ok := findChildMP4(data, stbl.body(), stbl.end(), "co64"); ok {
|
||||||
|
base := co64.body() + 4
|
||||||
|
if base+4 <= co64.end() {
|
||||||
|
count := int64(binary.BigEndian.Uint32(data[base : base+4]))
|
||||||
|
p := base + 4
|
||||||
|
for i := int64(0); i < count && p+8 <= co64.end(); i++ {
|
||||||
|
v := int64(binary.BigEndian.Uint64(data[p : p+8]))
|
||||||
|
if v >= insertPos {
|
||||||
|
binary.BigEndian.PutUint64(data[p:p+8], uint64(v+delta))
|
||||||
|
}
|
||||||
|
p += 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeQuickTimeAudioToMP4 rewrites a QuickTime-flavored file (FFmpeg mov
|
||||||
|
// muxer output: ftyp brand "qt " and a version-1 sound sample entry) into a
|
||||||
|
// standard ISO MP4: an isom/mp42 brand and a plain version-0 AudioSampleEntry.
|
||||||
|
// Windows Media Foundation (and other strict parsers) reject the QuickTime
|
||||||
|
// flavor for AC-4 even when dac4 is present.
|
||||||
|
func normalizeQuickTimeAudioToMP4(data []byte) []byte {
|
||||||
|
if ftyp, ok := findChildMP4(data, 0, int64(len(data)), "ftyp"); ok {
|
||||||
|
if ftyp.body()+4 <= int64(len(data)) {
|
||||||
|
copy(data[ftyp.body():ftyp.body()+4], []byte("mp42"))
|
||||||
|
}
|
||||||
|
for p := ftyp.body() + 8; p+4 <= ftyp.end(); p += 4 {
|
||||||
|
if string(data[p:p+4]) == "qt " {
|
||||||
|
copy(data[p:p+4], []byte("isom"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loc, ok := locateAC4Entry(data)
|
||||||
|
if !ok {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
entry := loc.entry
|
||||||
|
verPos := entry.body() + 8
|
||||||
|
if verPos+2 > entry.end() {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
if binary.BigEndian.Uint16(data[verPos:verPos+2]) != 1 {
|
||||||
|
return data // already v0 (or v2, left untouched)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The v1 QuickTime sound extension is the 16 bytes following the 20-byte v0
|
||||||
|
// audio fields (samplesPerPacket, bytesPerPacket, bytesPerFrame, bytesPerSample).
|
||||||
|
extStart := entry.body() + 8 + 20
|
||||||
|
extEnd := extStart + 16
|
||||||
|
if extEnd > entry.end() {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
delta := int64(-16)
|
||||||
|
|
||||||
|
binary.BigEndian.PutUint16(data[verPos:verPos+2], 0)
|
||||||
|
shiftChunkOffsets(data, loc.chain[0], extStart, delta)
|
||||||
|
for _, b := range loc.chain {
|
||||||
|
growBoxSize(data, b, delta)
|
||||||
|
}
|
||||||
|
growBoxSize(data, entry, delta)
|
||||||
|
|
||||||
|
out := make([]byte, 0, len(data)-16)
|
||||||
|
out = append(out, data[:extStart]...)
|
||||||
|
out = append(out, data[extEnd:]...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureAC4ConfigBox makes a decrypted AC-4 MP4 standards-compliant and
|
||||||
|
// playable: it normalizes FFmpeg's QuickTime-flavored mov output to an ISO MP4
|
||||||
|
// and injects the AC-4 configuration box (dac4) into the ac-4 sample entry. The
|
||||||
|
// dac4 box is copied verbatim from sourcePath (the original MP4, whose plaintext
|
||||||
|
// moov still carries it). No-op when the file has no AC-4 track.
|
||||||
|
func EnsureAC4ConfigBox(decryptedPath, sourcePath string) error {
|
||||||
|
dst, err := os.ReadFile(decryptedPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := locateAC4Entry(dst); !ok {
|
||||||
|
return nil // not an AC-4 file; nothing to do
|
||||||
|
}
|
||||||
|
|
||||||
|
dst = normalizeQuickTimeAudioToMP4(dst)
|
||||||
|
|
||||||
|
loc, ok := locateAC4Entry(dst)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hdrLen, ok := audioSampleEntryHeaderLen(dst, loc.entry)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("malformed ac-4 sample entry")
|
||||||
|
}
|
||||||
|
childStart := loc.entry.body() + hdrLen
|
||||||
|
if _, has := findChildMP4(dst, childStart, loc.entry.end(), "dac4"); has {
|
||||||
|
// Already has dac4; still persist any normalization changes.
|
||||||
|
return os.WriteFile(decryptedPath, dst, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := os.ReadFile(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
srcMoov, ok := findChildMP4(src, 0, int64(len(src)), "moov")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("source has no moov")
|
||||||
|
}
|
||||||
|
dac4Box, ok := findBoxBySignature(src, srcMoov.body(), srcMoov.end(), "dac4")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("dac4 not found in source")
|
||||||
|
}
|
||||||
|
dac4 := append([]byte{}, src[dac4Box.offset:dac4Box.end()]...)
|
||||||
|
|
||||||
|
insertPos := childStart
|
||||||
|
delta := int64(len(dac4))
|
||||||
|
|
||||||
|
shiftChunkOffsets(dst, loc.chain[0], insertPos, delta)
|
||||||
|
for _, b := range loc.chain {
|
||||||
|
growBoxSize(dst, b, delta)
|
||||||
|
}
|
||||||
|
growBoxSize(dst, loc.entry, delta)
|
||||||
|
|
||||||
|
out := make([]byte, 0, len(dst)+len(dac4))
|
||||||
|
out = append(out, dst[:insertPos]...)
|
||||||
|
out = append(out, dac4...)
|
||||||
|
out = append(out, dst[insertPos:]...)
|
||||||
|
|
||||||
|
return os.WriteFile(decryptedPath, out, 0o644)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mp4TestBox(typ string, body []byte) []byte {
|
||||||
|
out := make([]byte, 8+len(body))
|
||||||
|
binary.BigEndian.PutUint32(out[:4], uint32(len(out)))
|
||||||
|
copy(out[4:8], typ)
|
||||||
|
copy(out[8:], body)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mp4TestAC4Tree(entryBody []byte) []byte {
|
||||||
|
entry := mp4TestBox("ac-4", entryBody)
|
||||||
|
stsdBody := append([]byte{
|
||||||
|
0, 0, 0, 0, // version/flags
|
||||||
|
0, 0, 0, 1, // entry_count
|
||||||
|
}, entry...)
|
||||||
|
stsd := mp4TestBox("stsd", stsdBody)
|
||||||
|
stbl := mp4TestBox("stbl", stsd)
|
||||||
|
minf := mp4TestBox("minf", stbl)
|
||||||
|
mdia := mp4TestBox("mdia", minf)
|
||||||
|
trak := mp4TestBox("trak", mdia)
|
||||||
|
moov := mp4TestBox("moov", trak)
|
||||||
|
return moov
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortAC4SampleEntryBody(version uint16) []byte {
|
||||||
|
body := make([]byte, 10)
|
||||||
|
binary.BigEndian.PutUint16(body[8:10], version)
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeQuickTimeAudioToMP4IgnoresTruncatedAC4Entry(t *testing.T) {
|
||||||
|
input := mp4TestAC4Tree(shortAC4SampleEntryBody(1))
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("normalizeQuickTimeAudioToMP4 panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
got := normalizeQuickTimeAudioToMP4(append([]byte{}, input...))
|
||||||
|
if !bytes.Equal(got, input) {
|
||||||
|
t.Fatal("truncated QuickTime AC-4 entry should be left unchanged")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAC4ConfigBoxRejectsTruncatedAC4Entry(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
decryptedPath := filepath.Join(dir, "decrypted.mp4")
|
||||||
|
sourcePath := filepath.Join(dir, "source.mp4")
|
||||||
|
|
||||||
|
if err := os.WriteFile(decryptedPath, mp4TestAC4Tree(shortAC4SampleEntryBody(2)), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(sourcePath, mp4TestBox("moov", mp4TestBox("dac4", []byte{1, 2, 3, 4})), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("EnsureAC4ConfigBox panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := EnsureAC4ConfigBox(decryptedPath, sourcePath); err == nil {
|
||||||
|
t.Fatal("expected malformed AC-4 sample entry error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ac4Metadata mirrors the tag fields the app embeds for other formats. Numeric
|
||||||
|
// fields are strings because they arrive as a JSON-encoded map of strings.
|
||||||
|
type ac4Metadata struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Artist string `json:"artist"`
|
||||||
|
Album string `json:"album"`
|
||||||
|
AlbumArtist string `json:"albumArtist"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Genre string `json:"genre"`
|
||||||
|
Composer string `json:"composer"`
|
||||||
|
TrackNumber string `json:"trackNumber"`
|
||||||
|
TotalTracks string `json:"totalTracks"`
|
||||||
|
DiscNumber string `json:"discNumber"`
|
||||||
|
TotalDiscs string `json:"totalDiscs"`
|
||||||
|
ISRC string `json:"isrc"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Copyright string `json:"copyright"`
|
||||||
|
Lyrics string `json:"lyrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoiSafe(s string) int {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func itunesTextTag(atomType, value string) []byte {
|
||||||
|
data := make([]byte, 8+len(value))
|
||||||
|
binary.BigEndian.PutUint32(data[0:4], 1) // well-known type 1 = UTF-8
|
||||||
|
copy(data[8:], []byte(value))
|
||||||
|
return buildM4AAtom(atomType, buildM4AAtom("data", data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func itunesNumberPairTag(atomType string, number, total int) []byte {
|
||||||
|
payload := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint16(payload[2:4], uint16(number))
|
||||||
|
binary.BigEndian.PutUint16(payload[4:6], uint16(total))
|
||||||
|
data := make([]byte, 8+len(payload))
|
||||||
|
binary.BigEndian.PutUint32(data[0:4], 0) // type 0 = implicit/binary
|
||||||
|
copy(data[8:], payload)
|
||||||
|
return buildM4AAtom(atomType, buildM4AAtom("data", data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func itunesCoverTag(image []byte) []byte {
|
||||||
|
typeCode := uint32(13) // JPEG
|
||||||
|
if len(image) >= 8 &&
|
||||||
|
image[0] == 0x89 && image[1] == 0x50 && image[2] == 0x4E && image[3] == 0x47 {
|
||||||
|
typeCode = 14 // PNG
|
||||||
|
}
|
||||||
|
data := make([]byte, 8+len(image))
|
||||||
|
binary.BigEndian.PutUint32(data[0:4], typeCode)
|
||||||
|
copy(data[8:], image)
|
||||||
|
return buildM4AAtom("covr", buildM4AAtom("data", data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func itunesMetadataHandler() []byte {
|
||||||
|
payload := make([]byte, 0, 25)
|
||||||
|
payload = append(payload, 0, 0, 0, 0) // version + flags
|
||||||
|
payload = append(payload, 0, 0, 0, 0) // pre_defined
|
||||||
|
payload = append(payload, []byte("mdir")...) // handler type
|
||||||
|
payload = append(payload, []byte("appl")...) // reserved[0]
|
||||||
|
payload = append(payload, 0, 0, 0, 0, 0, 0, 0, 0) // reserved[1..2]
|
||||||
|
payload = append(payload, 0) // empty name
|
||||||
|
return buildM4AAtom("hdlr", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildITunesUdta assembles a fresh udta>meta>(hdlr+ilst) box from metadata.
|
||||||
|
func buildITunesUdta(md ac4Metadata, cover []byte) []byte {
|
||||||
|
ilst := make([]byte, 0, 256)
|
||||||
|
add := func(atomType, value string) {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
ilst = append(ilst, itunesTextTag(atomType, value)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("\xa9nam", md.Title)
|
||||||
|
add("\xa9ART", md.Artist)
|
||||||
|
add("\xa9alb", md.Album)
|
||||||
|
add("aART", md.AlbumArtist)
|
||||||
|
add("\xa9day", md.Date)
|
||||||
|
add("\xa9gen", md.Genre)
|
||||||
|
add("\xa9wrt", md.Composer)
|
||||||
|
if tn := atoiSafe(md.TrackNumber); tn > 0 {
|
||||||
|
ilst = append(ilst, itunesNumberPairTag("trkn", tn, atoiSafe(md.TotalTracks))...)
|
||||||
|
}
|
||||||
|
if dn := atoiSafe(md.DiscNumber); dn > 0 {
|
||||||
|
ilst = append(ilst, itunesNumberPairTag("disk", dn, atoiSafe(md.TotalDiscs))...)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(md.ISRC) != "" {
|
||||||
|
ilst = append(ilst, buildM4AFreeformAtom("ISRC", strings.TrimSpace(md.ISRC))...)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(md.Label) != "" {
|
||||||
|
ilst = append(ilst, buildM4AFreeformAtom("LABEL", strings.TrimSpace(md.Label))...)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(md.Copyright) != "" {
|
||||||
|
add("cprt", md.Copyright)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(md.Lyrics) != "" {
|
||||||
|
add("\xa9lyr", md.Lyrics)
|
||||||
|
}
|
||||||
|
if len(cover) > 0 {
|
||||||
|
ilst = append(ilst, itunesCoverTag(cover)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
ilstBox := buildM4AAtom("ilst", ilst)
|
||||||
|
metaPayload := append([]byte{0, 0, 0, 0}, itunesMetadataHandler()...)
|
||||||
|
metaPayload = append(metaPayload, ilstBox...)
|
||||||
|
meta := buildM4AAtom("meta", metaPayload)
|
||||||
|
return buildM4AAtom("udta", meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMP4iTunesMetadata replaces (or inserts) a udta>meta>ilst metadata box in
|
||||||
|
// the moov of an MP4 buffer and returns the rewritten bytes.
|
||||||
|
func writeMP4iTunesMetadata(data []byte, md ac4Metadata, cover []byte) []byte {
|
||||||
|
moov, ok := findChildMP4(data, 0, int64(len(data)), "moov")
|
||||||
|
if !ok {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
newUdta := buildITunesUdta(md, cover)
|
||||||
|
|
||||||
|
if udta, ok := findChildMP4(data, moov.body(), moov.end(), "udta"); ok {
|
||||||
|
delta := int64(len(newUdta)) - udta.size
|
||||||
|
shiftChunkOffsets(data, moov, udta.offset, delta)
|
||||||
|
growBoxSize(data, moov, delta)
|
||||||
|
out := make([]byte, 0, len(data)+len(newUdta))
|
||||||
|
out = append(out, data[:udta.offset]...)
|
||||||
|
out = append(out, newUdta...)
|
||||||
|
out = append(out, data[udta.end():]...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
delta := int64(len(newUdta))
|
||||||
|
insertPos := moov.end()
|
||||||
|
shiftChunkOffsets(data, moov, insertPos, delta)
|
||||||
|
growBoxSize(data, moov, delta)
|
||||||
|
out := make([]byte, 0, len(data)+len(newUdta))
|
||||||
|
out = append(out, data[:insertPos]...)
|
||||||
|
out = append(out, newUdta...)
|
||||||
|
out = append(out, data[insertPos:]...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteAC4MetadataIfApplicable writes iTunes metadata into an AC-4 MP4. Returns
|
||||||
|
// true when the file was an AC-4 track and metadata was written; false when the
|
||||||
|
// file is not AC-4 (the caller should fall back to its normal metadata path).
|
||||||
|
func WriteAC4MetadataIfApplicable(decryptedPath, metadataJSON, coverPath string) (bool, error) {
|
||||||
|
data, err := os.ReadFile(decryptedPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if _, ok := locateAC4Entry(data); !ok {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var md ac4Metadata
|
||||||
|
if strings.TrimSpace(metadataJSON) != "" {
|
||||||
|
_ = json.Unmarshal([]byte(metadataJSON), &md)
|
||||||
|
}
|
||||||
|
var cover []byte
|
||||||
|
if strings.TrimSpace(coverPath) != "" {
|
||||||
|
if b, err := os.ReadFile(coverPath); err == nil {
|
||||||
|
cover = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := writeMP4iTunesMetadata(data, md, cover)
|
||||||
|
if err := os.WriteFile(decryptedPath, out, 0o644); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -314,7 +314,6 @@ func marshalAPETag(tag *APETag) ([]byte, error) {
|
|||||||
footerFlags := uint32(1 << 31)
|
footerFlags := uint32(1 << 31)
|
||||||
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
|
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
|
||||||
|
|
||||||
// Final layout: header + items + footer
|
|
||||||
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
|
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
|
||||||
result = append(result, header...)
|
result = append(result, header...)
|
||||||
result = append(result, itemsData...)
|
result = append(result, itemsData...)
|
||||||
|
|||||||
@@ -1624,6 +1624,9 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
|
|||||||
}
|
}
|
||||||
return data, mimeType, nil
|
return data, mimeType, nil
|
||||||
|
|
||||||
|
case ".wav", ".aiff", ".aif", ".aifc":
|
||||||
|
return extractWAVAIFFCover(filePath)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CrossExtensionShareResult struct {
|
||||||
|
ExtensionID string `json:"extension_id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Found bool `json:"found"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
ItemName string `json:"item_name,omitempty"`
|
||||||
|
ItemArtists string `json:"item_artists,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var crossExtensionShareResultCache = struct {
|
||||||
|
sync.RWMutex
|
||||||
|
entries map[string]string
|
||||||
|
order []string
|
||||||
|
}{
|
||||||
|
entries: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
const crossExtensionShareResultCacheLimit = 128
|
||||||
|
|
||||||
|
func FindCollectionAcrossExtensionsJSON(requestJSON string) (string, error) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Artists string `json:"artists"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
SourceExtensionID string `json:"source_extension_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
req.Artists = strings.TrimSpace(req.Artists)
|
||||||
|
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
|
||||||
|
req.SourceExtensionID = strings.TrimSpace(req.SourceExtensionID)
|
||||||
|
if req.Name == "" {
|
||||||
|
return "[]", nil
|
||||||
|
}
|
||||||
|
if req.Type == "" {
|
||||||
|
req.Type = "album"
|
||||||
|
}
|
||||||
|
|
||||||
|
providers := getExtensionManager().GetMetadataProviders()
|
||||||
|
work := make([]*extensionProviderWrapper, 0, len(providers))
|
||||||
|
for _, provider := range providers {
|
||||||
|
if provider == nil || provider.extension == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if provider.extension.ID == req.SourceExtensionID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
work = append(work, provider)
|
||||||
|
}
|
||||||
|
cacheKey := crossExtensionShareCacheKey(req.Name, req.Artists, req.Type, req.SourceExtensionID, work)
|
||||||
|
if cached := getCrossExtensionShareCache(cacheKey); cached != "" {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query := req.Name
|
||||||
|
if req.Artists != "" {
|
||||||
|
query += " " + req.Artists
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]CrossExtensionShareResult, len(work))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, provider := range work {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(index int, p *extensionProviderWrapper) {
|
||||||
|
defer wg.Done()
|
||||||
|
results[index] = findCollectionForExtension(
|
||||||
|
p,
|
||||||
|
req.Type,
|
||||||
|
req.Name,
|
||||||
|
req.Artists,
|
||||||
|
query,
|
||||||
|
)
|
||||||
|
}(i, provider)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
data, err := json.Marshal(results)
|
||||||
|
if err != nil {
|
||||||
|
return "[]", err
|
||||||
|
}
|
||||||
|
response := string(data)
|
||||||
|
if crossExtensionShareResultsCacheable(results) {
|
||||||
|
setCrossExtensionShareCache(cacheKey, response)
|
||||||
|
}
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func crossExtensionShareCacheKey(name string, artists string, itemType string, sourceExtensionID string, providers []*extensionProviderWrapper) string {
|
||||||
|
providerKeys := make([]string, 0, len(providers))
|
||||||
|
for _, provider := range providers {
|
||||||
|
if provider == nil || provider.extension == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := provider.extension
|
||||||
|
displayName := ""
|
||||||
|
if ext.Manifest != nil {
|
||||||
|
displayName = ext.Manifest.DisplayName
|
||||||
|
}
|
||||||
|
providerKeys = append(providerKeys, strings.Join([]string{
|
||||||
|
strings.TrimSpace(ext.ID),
|
||||||
|
strings.TrimSpace(displayName),
|
||||||
|
strings.TrimSpace(ext.SourceDir),
|
||||||
|
}, "\x1f"))
|
||||||
|
}
|
||||||
|
sort.Strings(providerKeys)
|
||||||
|
|
||||||
|
return strings.Join([]string{
|
||||||
|
normalizeLooseTitle(itemType),
|
||||||
|
normalizeLooseTitle(name),
|
||||||
|
normalizeLooseArtistName(artists),
|
||||||
|
strings.TrimSpace(sourceExtensionID),
|
||||||
|
strings.Join(providerKeys, "\x1e"),
|
||||||
|
}, "\x1d")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCrossExtensionShareCache(key string) string {
|
||||||
|
if key == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
crossExtensionShareResultCache.RLock()
|
||||||
|
defer crossExtensionShareResultCache.RUnlock()
|
||||||
|
return crossExtensionShareResultCache.entries[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCrossExtensionShareCache(key string, value string) {
|
||||||
|
if key == "" || value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
crossExtensionShareResultCache.Lock()
|
||||||
|
defer crossExtensionShareResultCache.Unlock()
|
||||||
|
|
||||||
|
if _, exists := crossExtensionShareResultCache.entries[key]; !exists {
|
||||||
|
crossExtensionShareResultCache.order = append(crossExtensionShareResultCache.order, key)
|
||||||
|
}
|
||||||
|
crossExtensionShareResultCache.entries[key] = value
|
||||||
|
|
||||||
|
for len(crossExtensionShareResultCache.order) > crossExtensionShareResultCacheLimit {
|
||||||
|
oldest := crossExtensionShareResultCache.order[0]
|
||||||
|
crossExtensionShareResultCache.order = crossExtensionShareResultCache.order[1:]
|
||||||
|
delete(crossExtensionShareResultCache.entries, oldest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func crossExtensionShareResultsCacheable(results []CrossExtensionShareResult) bool {
|
||||||
|
for _, result := range results {
|
||||||
|
if result.Found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
errText := strings.ToLower(strings.TrimSpace(result.Error))
|
||||||
|
if errText == "" ||
|
||||||
|
errText == "no results" ||
|
||||||
|
errText == "unsupported collection type" ||
|
||||||
|
strings.HasSuffix(errText, " not found") ||
|
||||||
|
strings.Contains(errText, "found without shareable link") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func findCollectionForExtension(
|
||||||
|
provider *extensionProviderWrapper,
|
||||||
|
itemType string,
|
||||||
|
name string,
|
||||||
|
artists string,
|
||||||
|
query string,
|
||||||
|
) CrossExtensionShareResult {
|
||||||
|
result := CrossExtensionShareResult{
|
||||||
|
ExtensionID: provider.extension.ID,
|
||||||
|
}
|
||||||
|
if provider.extension.Manifest != nil {
|
||||||
|
result.DisplayName = provider.extension.Manifest.DisplayName
|
||||||
|
}
|
||||||
|
if result.DisplayName == "" {
|
||||||
|
result.DisplayName = provider.extension.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
searchResult, err := searchCollectionCandidates(provider, itemType, query)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if searchResult == nil || len(searchResult.Tracks) == 0 {
|
||||||
|
result.Error = "no results"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
var best *ExtTrackMetadata
|
||||||
|
switch itemType {
|
||||||
|
case "artist":
|
||||||
|
best = bestArtistTrack(searchResult.Tracks, name)
|
||||||
|
case "album":
|
||||||
|
best = bestAlbumTrack(searchResult.Tracks, name, artists)
|
||||||
|
default:
|
||||||
|
result.Error = "unsupported collection type"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if best == nil {
|
||||||
|
result.Error = itemType + " not found"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
url := resolveCollectionShareURL(provider.extension, itemType, best)
|
||||||
|
if url == "" {
|
||||||
|
result.Error = itemType + " found without shareable link"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Found = true
|
||||||
|
result.URL = url
|
||||||
|
if itemType == "artist" {
|
||||||
|
result.ItemName = collectionArtistName(*best)
|
||||||
|
} else {
|
||||||
|
result.ItemName = collectionAlbumName(*best)
|
||||||
|
result.ItemArtists = best.Artists
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchCollectionCandidates(provider *extensionProviderWrapper, itemType string, query string) (*ExtSearchResult, error) {
|
||||||
|
filter := ""
|
||||||
|
switch itemType {
|
||||||
|
case "album":
|
||||||
|
filter = "albums"
|
||||||
|
case "artist":
|
||||||
|
filter = "artists"
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter != "" {
|
||||||
|
tracks, err := provider.CustomSearch(query, map[string]interface{}{
|
||||||
|
"filter": filter,
|
||||||
|
"limit": 10,
|
||||||
|
})
|
||||||
|
if err == nil && len(tracks) > 0 {
|
||||||
|
return &ExtSearchResult{Tracks: tracks, Total: len(tracks)}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider.SearchTracks(query, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bestAlbumTrack(tracks []ExtTrackMetadata, albumName string, artists string) *ExtTrackMetadata {
|
||||||
|
targetAlbum := normalizeLooseTitle(albumName)
|
||||||
|
targetArtists := normalizeLooseArtistName(artists)
|
||||||
|
bestScore := 0
|
||||||
|
bestIndex := -1
|
||||||
|
|
||||||
|
for i := range tracks {
|
||||||
|
track := tracks[i]
|
||||||
|
album := normalizeLooseTitle(collectionAlbumName(track))
|
||||||
|
trackArtists := normalizeLooseArtistName(track.Artists + " " + track.AlbumArtist)
|
||||||
|
|
||||||
|
score := 0
|
||||||
|
if isCollectionItemType(track, "album") {
|
||||||
|
score += 25
|
||||||
|
}
|
||||||
|
if album == targetAlbum {
|
||||||
|
score += 100
|
||||||
|
} else if album != "" && targetAlbum != "" && (strings.Contains(album, targetAlbum) || strings.Contains(targetAlbum, album)) {
|
||||||
|
score += 50
|
||||||
|
}
|
||||||
|
if targetArtists != "" && (strings.Contains(trackArtists, targetArtists) || strings.Contains(targetArtists, trackArtists)) {
|
||||||
|
score += 30
|
||||||
|
}
|
||||||
|
if score > bestScore {
|
||||||
|
bestScore = score
|
||||||
|
bestIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestIndex < 0 || bestScore < 50 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &tracks[bestIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
func bestArtistTrack(tracks []ExtTrackMetadata, artistName string) *ExtTrackMetadata {
|
||||||
|
targetArtist := normalizeLooseArtistName(artistName)
|
||||||
|
bestScore := 0
|
||||||
|
bestIndex := -1
|
||||||
|
|
||||||
|
for i := range tracks {
|
||||||
|
artist := normalizeLooseArtistName(collectionArtistName(tracks[i]))
|
||||||
|
score := 0
|
||||||
|
if isCollectionItemType(tracks[i], "artist") {
|
||||||
|
score += 25
|
||||||
|
}
|
||||||
|
if artist == targetArtist {
|
||||||
|
score += 100
|
||||||
|
} else if artist != "" && targetArtist != "" && (strings.Contains(artist, targetArtist) || strings.Contains(targetArtist, artist)) {
|
||||||
|
score += 60
|
||||||
|
}
|
||||||
|
if score > bestScore {
|
||||||
|
bestScore = score
|
||||||
|
bestIndex = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestIndex < 0 || bestScore < 60 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &tracks[bestIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *ExtTrackMetadata) string {
|
||||||
|
if track == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if itemType == "album" {
|
||||||
|
if isCollectionItemType(*track, "album") {
|
||||||
|
if url := normalizeShareURL(track.ExternalURL); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if url := normalizeShareURL(track.AlbumURL); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if url := templateShareURL(ext, "album", firstNonEmptyString(track.AlbumID, collectionID(*track, "album"), track.AlbumURL)); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if isCollectionItemType(*track, "artist") {
|
||||||
|
if url := normalizeShareURL(track.ExternalURL); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if url := normalizeShareURL(track.ArtistURL); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if url := templateShareURL(ext, "artist", firstNonEmptyString(track.ArtistID, collectionID(*track, "artist"))); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionAlbumName(track ExtTrackMetadata) string {
|
||||||
|
if isCollectionItemType(track, "album") {
|
||||||
|
return track.Name
|
||||||
|
}
|
||||||
|
return track.AlbumName
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionArtistName(track ExtTrackMetadata) string {
|
||||||
|
if isCollectionItemType(track, "artist") {
|
||||||
|
return track.Name
|
||||||
|
}
|
||||||
|
return track.Artists
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectionID(track ExtTrackMetadata, itemType string) string {
|
||||||
|
if isCollectionItemType(track, itemType) {
|
||||||
|
return track.ID
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCollectionItemType(track ExtTrackMetadata, itemType string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(track.ItemType), itemType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeShareURL(value string) string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlFromExternalLinks(links map[string]string, preferredKey string) string {
|
||||||
|
for key, value := range links {
|
||||||
|
if strings.Contains(strings.ToLower(key), preferredKey) {
|
||||||
|
if url := normalizeShareURL(value); url != "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func templateShareURL(ext *loadedExtension, itemType string, id string) string {
|
||||||
|
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
id = stripProviderPrefix(strings.TrimSpace(id))
|
||||||
|
if id == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rawTemplate, ok := templates[itemType].(string)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rawTemplate = strings.TrimSpace(rawTemplate)
|
||||||
|
if rawTemplate == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(rawTemplate, "{id}", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripProviderPrefix(id string) string {
|
||||||
|
if index := strings.Index(id, ":"); index > 0 && index < len(id)-1 {
|
||||||
|
return id[index+1:]
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyString(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
|
||||||
|
ext := &loadedExtension{
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Capabilities: map[string]interface{}{
|
||||||
|
"shareUrlTemplates": map[string]interface{}{
|
||||||
|
"album": "https://music.apple.com/us/album/{id}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tracks := []ExtTrackMetadata{
|
||||||
|
{
|
||||||
|
ID: "1440783617",
|
||||||
|
Name: "Nevermind",
|
||||||
|
Artists: "Nirvana",
|
||||||
|
ItemType: "album",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
best := bestAlbumTrack(tracks, "Nevermind", "Nirvana")
|
||||||
|
if best == nil {
|
||||||
|
t.Fatal("expected album collection item to match")
|
||||||
|
}
|
||||||
|
if url := resolveCollectionShareURL(ext, "album", best); url != "https://music.apple.com/us/album/1440783617" {
|
||||||
|
t.Fatalf("album share URL = %q", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) {
|
||||||
|
ext := &loadedExtension{
|
||||||
|
Manifest: &ExtensionManifest{
|
||||||
|
Capabilities: map[string]interface{}{
|
||||||
|
"shareUrlTemplates": map[string]interface{}{
|
||||||
|
"artist": "https://music.youtube.com/browse/{id}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tracks := []ExtTrackMetadata{
|
||||||
|
{
|
||||||
|
ID: "UCrPe3hLA51968GwxHSZ1llw",
|
||||||
|
Name: "Nirvana",
|
||||||
|
ItemType: "artist",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
best := bestArtistTrack(tracks, "Nirvana")
|
||||||
|
if best == nil {
|
||||||
|
t.Fatal("expected artist collection item to match")
|
||||||
|
}
|
||||||
|
if url := resolveCollectionShareURL(ext, "artist", best); url != "https://music.youtube.com/browse/UCrPe3hLA51968GwxHSZ1llw" {
|
||||||
|
t.Fatalf("artist share URL = %q", url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossExtensionShareCacheKeyIsProviderOrderStable(t *testing.T) {
|
||||||
|
apple := &extensionProviderWrapper{
|
||||||
|
extension: &loadedExtension{
|
||||||
|
ID: "apple",
|
||||||
|
SourceDir: "/extensions/apple",
|
||||||
|
Manifest: &ExtensionManifest{DisplayName: "Apple Music"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
qobuz := &extensionProviderWrapper{
|
||||||
|
extension: &loadedExtension{
|
||||||
|
ID: "qobuz",
|
||||||
|
SourceDir: "/extensions/qobuz",
|
||||||
|
Manifest: &ExtensionManifest{DisplayName: "Qobuz"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
first := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{apple, qobuz})
|
||||||
|
second := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{qobuz, apple})
|
||||||
|
if first != second {
|
||||||
|
t.Fatalf("cache key should not depend on provider order:\n%s\n%s", first, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrossExtensionShareCacheableSkipsTransientErrors(t *testing.T) {
|
||||||
|
cacheable := []CrossExtensionShareResult{
|
||||||
|
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
|
||||||
|
{ExtensionID: "qobuz", Error: "album not found"},
|
||||||
|
{ExtensionID: "tidal", Error: "no results"},
|
||||||
|
}
|
||||||
|
if !crossExtensionShareResultsCacheable(cacheable) {
|
||||||
|
t.Fatal("expected found and deterministic not-found results to be cacheable")
|
||||||
|
}
|
||||||
|
|
||||||
|
transient := []CrossExtensionShareResult{
|
||||||
|
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
|
||||||
|
{ExtensionID: "qobuz", Error: "request failed: timeout"},
|
||||||
|
}
|
||||||
|
if crossExtensionShareResultsCacheable(transient) {
|
||||||
|
t.Fatal("expected transient extension errors to skip cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -264,7 +264,7 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
|
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
|
||||||
commonExts := []string{".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
commonExts := []string{".flac", ".wav", ".aiff", ".aif", ".ape", ".mp3", ".ogg", ".wv", ".m4a"}
|
||||||
for _, ext := range commonExts {
|
for _, ext := range commonExts {
|
||||||
candidate = filepath.Join(cueDir, baseName+ext)
|
candidate = filepath.Join(cueDir, baseName+ext)
|
||||||
if _, err := os.Stat(candidate); err == nil {
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
|||||||
+23
-12
@@ -3,6 +3,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -783,7 +784,6 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
// not include this field. Albums whose track count is already known (non-zero)
|
// not include this field. Albums whose track count is already known (non-zero)
|
||||||
// are skipped.
|
// are skipped.
|
||||||
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
|
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
|
||||||
// Find albums that need track counts
|
|
||||||
type indexedID struct {
|
type indexedID struct {
|
||||||
idx int
|
idx int
|
||||||
albumID string
|
albumID string
|
||||||
@@ -1267,16 +1267,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
errStr := err.Error()
|
if !isDeezerRetryableError(err) {
|
||||||
|
|
||||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
|
||||||
strings.Contains(errStr, "connection reset") ||
|
|
||||||
strings.Contains(errStr, "connection refused") ||
|
|
||||||
strings.Contains(errStr, "EOF") ||
|
|
||||||
strings.Contains(errStr, "status 5") ||
|
|
||||||
strings.Contains(errStr, "status 429")
|
|
||||||
|
|
||||||
if !isRetryable {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1286,6 +1277,26 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
|||||||
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
|
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type deezerAPIError struct {
|
||||||
|
StatusCode int
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *deezerAPIError) Error() string {
|
||||||
|
return fmt.Sprintf("deezer API returned status %d: %s", e.StatusCode, e.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDeezerRetryableError(err error) bool {
|
||||||
|
if isConnectivityFailure(err) || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var apiErr *deezerAPIError
|
||||||
|
if errors.As(err, &apiErr) {
|
||||||
|
return apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode >= http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1306,7 +1317,7 @@ func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst inter
|
|||||||
}
|
}
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body))
|
return &deezerAPIError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.Unmarshal(body, dst)
|
return json.Unmarshal(body, dst)
|
||||||
|
|||||||
+352
-54
@@ -283,6 +283,7 @@ type DownloadRequest struct {
|
|||||||
PostProcessingEnabled bool `json:"post_processing_enabled,omitempty"`
|
PostProcessingEnabled bool `json:"post_processing_enabled,omitempty"`
|
||||||
TidalHighFormat string `json:"tidal_high_format,omitempty"`
|
TidalHighFormat string `json:"tidal_high_format,omitempty"`
|
||||||
TrackNumber int `json:"track_number"`
|
TrackNumber int `json:"track_number"`
|
||||||
|
PlaylistPosition int `json:"playlist_position,omitempty"`
|
||||||
DiscNumber int `json:"disc_number"`
|
DiscNumber int `json:"disc_number"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
TotalDiscs int `json:"total_discs,omitempty"`
|
TotalDiscs int `json:"total_discs,omitempty"`
|
||||||
@@ -310,6 +311,7 @@ type DownloadResponse struct {
|
|||||||
FilePath string `json:"file_path,omitempty"`
|
FilePath string `json:"file_path,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
ErrorType string `json:"error_type,omitempty"`
|
ErrorType string `json:"error_type,omitempty"`
|
||||||
|
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
|
||||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||||
@@ -379,6 +381,7 @@ type reEnrichRequest struct {
|
|||||||
CoverURL string `json:"cover_url"`
|
CoverURL string `json:"cover_url"`
|
||||||
MaxQuality bool `json:"max_quality"`
|
MaxQuality bool `json:"max_quality"`
|
||||||
EmbedLyrics bool `json:"embed_lyrics"`
|
EmbedLyrics bool `json:"embed_lyrics"`
|
||||||
|
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||||
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
|
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
|
||||||
SpotifyID string `json:"spotify_id"`
|
SpotifyID string `json:"spotify_id"`
|
||||||
TrackName string `json:"track_name"`
|
TrackName string `json:"track_name"`
|
||||||
@@ -414,6 +417,21 @@ func (r *reEnrichRequest) shouldUpdateField(field string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lyricsEmbedEnabled reports whether lyrics should be written into the audio
|
||||||
|
// file's tags. It mirrors the download path semantics: 'embed' and 'both' embed,
|
||||||
|
// 'external' does not. An empty mode keeps the legacy behavior (embed) so older
|
||||||
|
// callers that do not send lyrics_mode are unaffected.
|
||||||
|
func (r *reEnrichRequest) lyricsEmbedEnabled() bool {
|
||||||
|
return strings.ToLower(strings.TrimSpace(r.LyricsMode)) != "external"
|
||||||
|
}
|
||||||
|
|
||||||
|
// lyricsSidecarEnabled reports whether a .lrc sidecar file should be written
|
||||||
|
// next to the audio file. Only 'external' and 'both' request a sidecar.
|
||||||
|
func (r *reEnrichRequest) lyricsSidecarEnabled() bool {
|
||||||
|
mode := strings.ToLower(strings.TrimSpace(r.LyricsMode))
|
||||||
|
return mode == "external" || mode == "both"
|
||||||
|
}
|
||||||
|
|
||||||
func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return
|
return
|
||||||
@@ -578,7 +596,7 @@ func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if req.shouldUpdateField("lyrics") {
|
if req.shouldUpdateField("lyrics") {
|
||||||
if lyricsLRC != "" {
|
if lyricsLRC != "" && req.lyricsEmbedEnabled() {
|
||||||
metadata["LYRICS"] = lyricsLRC
|
metadata["LYRICS"] = lyricsLRC
|
||||||
metadata["UNSYNCEDLYRICS"] = lyricsLRC
|
metadata["UNSYNCEDLYRICS"] = lyricsLRC
|
||||||
}
|
}
|
||||||
@@ -594,12 +612,24 @@ func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *Ex
|
|||||||
downloadReq := reEnrichDownloadRequest(req)
|
downloadReq := reEnrichDownloadRequest(req)
|
||||||
currentISRC := strings.TrimSpace(req.ISRC)
|
currentISRC := strings.TrimSpace(req.ISRC)
|
||||||
currentAlbum := strings.TrimSpace(req.AlbumName)
|
currentAlbum := strings.TrimSpace(req.AlbumName)
|
||||||
|
effectiveTrackName := req.TrackName
|
||||||
|
if isPlaceholderReEnrichValue(effectiveTrackName) {
|
||||||
|
effectiveTrackName = ""
|
||||||
|
}
|
||||||
|
effectiveArtistName := req.ArtistName
|
||||||
|
if isPlaceholderReEnrichValue(effectiveArtistName) {
|
||||||
|
effectiveArtistName = ""
|
||||||
|
}
|
||||||
var best *ExtTrackMetadata
|
var best *ExtTrackMetadata
|
||||||
bestScore := -1 << 30
|
bestScore := -1 << 30
|
||||||
|
|
||||||
for i := range tracks {
|
for i := range tracks {
|
||||||
track := &tracks[i]
|
track := &tracks[i]
|
||||||
score := 0
|
score := 0
|
||||||
|
exactISRCMatch := currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC))
|
||||||
|
titleMatches := effectiveTrackName != "" && track.Name != "" && titlesMatch(effectiveTrackName, track.Name)
|
||||||
|
artistMatches := effectiveArtistName != "" && track.Artists != "" && artistsMatch(effectiveArtistName, track.Artists)
|
||||||
|
albumMatches := currentAlbum != "" && track.AlbumName != "" && titlesMatch(currentAlbum, track.AlbumName)
|
||||||
|
|
||||||
resolved := resolvedTrackInfo{
|
resolved := resolvedTrackInfo{
|
||||||
Title: track.Name,
|
Title: track.Name,
|
||||||
@@ -607,22 +637,39 @@ func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *Ex
|
|||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
Duration: track.DurationMS / 1000,
|
Duration: track.DurationMS / 1000,
|
||||||
}
|
}
|
||||||
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
|
verified := trackMatchesRequest(downloadReq, resolved, "ReEnrich")
|
||||||
|
|
||||||
|
if !exactISRCMatch {
|
||||||
|
if effectiveTrackName != "" && !titleMatches {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if effectiveArtistName != "" && !artistMatches {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum != "" && !albumMatches {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum == "" && !verified {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if verified {
|
||||||
score += 2000
|
score += 2000
|
||||||
}
|
}
|
||||||
|
|
||||||
if currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC)) {
|
if exactISRCMatch {
|
||||||
score += 10000
|
score += 10000
|
||||||
}
|
}
|
||||||
if req.TrackName != "" && track.Name != "" && titlesMatch(req.TrackName, track.Name) {
|
if titleMatches {
|
||||||
score += 400
|
score += 400
|
||||||
}
|
}
|
||||||
if req.ArtistName != "" && track.Artists != "" && artistsMatch(req.ArtistName, track.Artists) {
|
if artistMatches {
|
||||||
score += 320
|
score += 320
|
||||||
}
|
}
|
||||||
if currentAlbum != "" && track.AlbumName != "" {
|
if currentAlbum != "" && track.AlbumName != "" {
|
||||||
switch {
|
switch {
|
||||||
case titlesMatch(currentAlbum, track.AlbumName):
|
case albumMatches:
|
||||||
score += 120
|
score += 120
|
||||||
case strings.Contains(strings.ToLower(track.AlbumName), strings.ToLower(currentAlbum)),
|
case strings.Contains(strings.ToLower(track.AlbumName), strings.ToLower(currentAlbum)),
|
||||||
strings.Contains(strings.ToLower(currentAlbum), strings.ToLower(track.AlbumName)):
|
strings.Contains(strings.ToLower(currentAlbum), strings.ToLower(track.AlbumName)):
|
||||||
@@ -1115,6 +1162,8 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
isApe := strings.HasSuffix(lower, ".ape")
|
isApe := strings.HasSuffix(lower, ".ape")
|
||||||
isWv := strings.HasSuffix(lower, ".wv")
|
isWv := strings.HasSuffix(lower, ".wv")
|
||||||
isMpc := strings.HasSuffix(lower, ".mpc")
|
isMpc := strings.HasSuffix(lower, ".mpc")
|
||||||
|
isWav := strings.HasSuffix(lower, ".wav")
|
||||||
|
isAiff := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
|
||||||
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"title": "",
|
"title": "",
|
||||||
@@ -1331,7 +1380,6 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
} else if isApe || isWv || isMpc {
|
} else if isApe || isWv || isMpc {
|
||||||
result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".")
|
result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".")
|
||||||
result["audio_codec"] = result["format"]
|
result["audio_codec"] = result["format"]
|
||||||
// APE, WavPack, Musepack: read APEv2 tags
|
|
||||||
apeTag, apeErr := ReadAPETags(filePath)
|
apeTag, apeErr := ReadAPETags(filePath)
|
||||||
if apeErr == nil && apeTag != nil {
|
if apeErr == nil && apeTag != nil {
|
||||||
meta := APETagToAudioMetadata(apeTag)
|
meta := APETagToAudioMetadata(apeTag)
|
||||||
@@ -1361,6 +1409,51 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak
|
result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if isWav || isAiff {
|
||||||
|
var meta *AudioMetadata
|
||||||
|
var quality *WAVQuality
|
||||||
|
var qualityErr error
|
||||||
|
if isAiff {
|
||||||
|
result["format"] = "aiff"
|
||||||
|
result["audio_codec"] = "pcm"
|
||||||
|
meta, _ = ReadAIFFTags(filePath)
|
||||||
|
quality, qualityErr = GetAIFFQuality(filePath)
|
||||||
|
} else {
|
||||||
|
result["format"] = "wav"
|
||||||
|
result["audio_codec"] = "pcm"
|
||||||
|
meta, _ = ReadWAVTags(filePath)
|
||||||
|
quality, qualityErr = GetWAVQuality(filePath)
|
||||||
|
}
|
||||||
|
if meta != nil {
|
||||||
|
result["title"] = meta.Title
|
||||||
|
result["artist"] = meta.Artist
|
||||||
|
result["album"] = meta.Album
|
||||||
|
result["album_artist"] = meta.AlbumArtist
|
||||||
|
result["date"] = meta.Date
|
||||||
|
if meta.Date == "" {
|
||||||
|
result["date"] = meta.Year
|
||||||
|
}
|
||||||
|
result["track_number"] = meta.TrackNumber
|
||||||
|
result["total_tracks"] = meta.TotalTracks
|
||||||
|
result["disc_number"] = meta.DiscNumber
|
||||||
|
result["total_discs"] = meta.TotalDiscs
|
||||||
|
result["isrc"] = meta.ISRC
|
||||||
|
result["lyrics"] = meta.Lyrics
|
||||||
|
result["genre"] = meta.Genre
|
||||||
|
result["label"] = meta.Label
|
||||||
|
result["copyright"] = meta.Copyright
|
||||||
|
result["composer"] = meta.Composer
|
||||||
|
result["comment"] = meta.Comment
|
||||||
|
result["replaygain_track_gain"] = meta.ReplayGainTrackGain
|
||||||
|
result["replaygain_track_peak"] = meta.ReplayGainTrackPeak
|
||||||
|
result["replaygain_album_gain"] = meta.ReplayGainAlbumGain
|
||||||
|
result["replaygain_album_peak"] = meta.ReplayGainAlbumPeak
|
||||||
|
}
|
||||||
|
if qualityErr == nil && quality != nil {
|
||||||
|
result["bit_depth"] = quality.BitDepth
|
||||||
|
result["sample_rate"] = quality.SampleRate
|
||||||
|
result["duration"] = quality.Duration
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return "", fmt.Errorf("unsupported file format: %s", filePath)
|
return "", fmt.Errorf("unsupported file format: %s", filePath)
|
||||||
}
|
}
|
||||||
@@ -1418,6 +1511,48 @@ func ScanCueSheetForLibraryWithCoverCacheKey(cuePath, audioDir, virtualPathPrefi
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WriteM4AFreeformTags writes ISRC and label into an M4A/MP4 file as iTunes
|
||||||
|
// freeform atoms. FFmpeg's MP4 muxer ignores these keys, so they must be
|
||||||
|
// written natively after the FFmpeg metadata pass for the values to persist.
|
||||||
|
// Only keys present in the JSON are touched; an empty value clears the tag.
|
||||||
|
func WriteM4AFreeformTags(filePath, metadataJSON string) (string, error) {
|
||||||
|
var fields map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid metadata JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EditM4AFreeformText(filePath, fields); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write M4A freeform tags: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := map[string]any{"success": true, "method": "native_m4a_freeform"}
|
||||||
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureAC4Config normalizes a decrypted AC-4 file to a standards-compliant ISO
|
||||||
|
// MP4 and injects the dac4 configuration box copied from sourcePath. No-op when
|
||||||
|
// the file is not AC-4.
|
||||||
|
func EnsureAC4Config(filePath, sourcePath string) (string, error) {
|
||||||
|
if err := EnsureAC4ConfigBox(filePath, sourcePath); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to finalize AC-4 container: %w", err)
|
||||||
|
}
|
||||||
|
return `{"success":true}`, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteAC4Metadata writes iTunes-style metadata into an AC-4 MP4. The JSON
|
||||||
|
// "handled" field reports whether the file was AC-4 (true) so the caller can
|
||||||
|
// skip the FFmpeg metadata pass that would re-wrap it as QuickTime.
|
||||||
|
func WriteAC4Metadata(filePath, metadataJSON, coverPath string) (string, error) {
|
||||||
|
handled, err := WriteAC4MetadataIfApplicable(filePath, metadataJSON, coverPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write AC-4 metadata: %w", err)
|
||||||
|
}
|
||||||
|
resp := map[string]any{"success": true, "handled": handled}
|
||||||
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
// EditFileMetadata writes audio file tags: FLAC via native Go library, MP3/Opus returns map for Dart/FFmpeg.
|
// EditFileMetadata writes audio file tags: FLAC via native Go library, MP3/Opus returns map for Dart/FFmpeg.
|
||||||
func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||||
var fields map[string]string
|
var fields map[string]string
|
||||||
@@ -1429,6 +1564,8 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
|||||||
isFlac := strings.HasSuffix(lower, ".flac")
|
isFlac := strings.HasSuffix(lower, ".flac")
|
||||||
isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc")
|
isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc")
|
||||||
isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b")
|
isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b")
|
||||||
|
isWavFile := strings.HasSuffix(lower, ".wav")
|
||||||
|
isAiffFile := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
|
||||||
coverPath := strings.TrimSpace(fields["cover_path"])
|
coverPath := strings.TrimSpace(fields["cover_path"])
|
||||||
|
|
||||||
if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) {
|
if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) {
|
||||||
@@ -1457,7 +1594,24 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// APE/WV/MPC: write APEv2 tags natively
|
// WAV / AIFF: write tags into an embedded ID3v2.4 chunk natively.
|
||||||
|
if isWavFile {
|
||||||
|
if err := WriteWAVTags(filePath, fields); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write WAV metadata: %w", err)
|
||||||
|
}
|
||||||
|
resp := map[string]any{"success": true, "method": "native_wav"}
|
||||||
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
if isAiffFile {
|
||||||
|
if err := WriteAIFFTags(filePath, fields); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write AIFF metadata: %w", err)
|
||||||
|
}
|
||||||
|
resp := map[string]any{"success": true, "method": "native_aiff"}
|
||||||
|
jsonBytes, _ := json.Marshal(resp)
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
if isApeFile {
|
if isApeFile {
|
||||||
trackNum := 0
|
trackNum := 0
|
||||||
totalTracks := 0
|
totalTracks := 0
|
||||||
@@ -1706,9 +1860,13 @@ func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath st
|
|||||||
if filePath != "" {
|
if filePath != "" {
|
||||||
lyrics, err := ExtractLyrics(filePath)
|
lyrics, err := ExtractLyrics(filePath)
|
||||||
if err == nil && lyrics != "" {
|
if err == nil && lyrics != "" {
|
||||||
|
source := extractLyricsSourceFromLRC(lyrics)
|
||||||
|
if source == "" {
|
||||||
|
source = "Embedded"
|
||||||
|
}
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"lyrics": lyrics,
|
"lyrics": lyrics,
|
||||||
"source": "Embedded",
|
"source": source,
|
||||||
"sync_type": "EMBEDDED",
|
"sync_type": "EMBEDDED",
|
||||||
"instrumental": false,
|
"instrumental": false,
|
||||||
}
|
}
|
||||||
@@ -1911,9 +2069,15 @@ func normalizeExtensionTrackMetadataMap(
|
|||||||
"artists": track.Artists,
|
"artists": track.Artists,
|
||||||
"album_name": track.AlbumName,
|
"album_name": track.AlbumName,
|
||||||
"album_artist": track.AlbumArtist,
|
"album_artist": track.AlbumArtist,
|
||||||
|
"album_id": track.AlbumID,
|
||||||
|
"album_url": track.AlbumURL,
|
||||||
|
"artist_id": track.ArtistID,
|
||||||
|
"artist_url": track.ArtistURL,
|
||||||
|
"external_urls": track.ExternalURL,
|
||||||
"duration_ms": track.DurationMS,
|
"duration_ms": track.DurationMS,
|
||||||
"images": coverURL,
|
"images": coverURL,
|
||||||
"cover_url": coverURL,
|
"cover_url": coverURL,
|
||||||
|
"preview_url": track.PreviewURL,
|
||||||
"release_date": track.ReleaseDate,
|
"release_date": track.ReleaseDate,
|
||||||
"track_number": trackNum,
|
"track_number": trackNum,
|
||||||
"total_tracks": track.TotalTracks,
|
"total_tracks": track.TotalTracks,
|
||||||
@@ -1942,9 +2106,12 @@ func normalizeExtensionAlbumInfoMap(album *ExtAlbumMetadata) map[string]interfac
|
|||||||
"artist_id": album.ArtistID,
|
"artist_id": album.ArtistID,
|
||||||
"images": album.CoverURL,
|
"images": album.CoverURL,
|
||||||
"cover_url": album.CoverURL,
|
"cover_url": album.CoverURL,
|
||||||
|
"header_image": album.HeaderImage,
|
||||||
|
"header_video": album.HeaderVideo,
|
||||||
"release_date": album.ReleaseDate,
|
"release_date": album.ReleaseDate,
|
||||||
"total_tracks": album.TotalTracks,
|
"total_tracks": album.TotalTracks,
|
||||||
"album_type": album.AlbumType,
|
"album_type": album.AlbumType,
|
||||||
|
"audio_traits": album.AudioTraits,
|
||||||
"provider_id": album.ProviderID,
|
"provider_id": album.ProviderID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2029,11 +2196,13 @@ func getExtensionProviderMetadataResponse(
|
|||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"playlist_info": map[string]interface{}{
|
"playlist_info": map[string]interface{}{
|
||||||
"id": playlist.ID,
|
"id": playlist.ID,
|
||||||
"name": playlist.Name,
|
"name": playlist.Name,
|
||||||
"images": playlist.CoverURL,
|
"images": playlist.CoverURL,
|
||||||
"cover_url": playlist.CoverURL,
|
"cover_url": playlist.CoverURL,
|
||||||
"provider_id": playlist.ProviderID,
|
"header_image": playlist.HeaderImage,
|
||||||
|
"header_video": playlist.HeaderVideo,
|
||||||
|
"provider_id": playlist.ProviderID,
|
||||||
"owner": map[string]interface{}{
|
"owner": map[string]interface{}{
|
||||||
"name": playlist.Artists,
|
"name": playlist.Artists,
|
||||||
"images": playlist.CoverURL,
|
"images": playlist.CoverURL,
|
||||||
@@ -2062,6 +2231,7 @@ func getExtensionProviderMetadataResponse(
|
|||||||
"images": firstNonEmptyTrimmed(artist.HeaderImage, artist.ImageURL),
|
"images": firstNonEmptyTrimmed(artist.HeaderImage, artist.ImageURL),
|
||||||
"cover_url": artist.ImageURL,
|
"cover_url": artist.ImageURL,
|
||||||
"header_image": artist.HeaderImage,
|
"header_image": artist.HeaderImage,
|
||||||
|
"header_video": artist.HeaderVideo,
|
||||||
"provider_id": artist.ProviderID,
|
"provider_id": artist.ProviderID,
|
||||||
},
|
},
|
||||||
"albums": albums,
|
"albums": albums,
|
||||||
@@ -2111,6 +2281,16 @@ func GetProviderMetadataJSON(providerID, resourceType, resourceID string) (strin
|
|||||||
|
|
||||||
switch strings.ToLower(trimmedProviderID) {
|
switch strings.ToLower(trimmedProviderID) {
|
||||||
case "deezer":
|
case "deezer":
|
||||||
|
if response, ok, err := getEnabledExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID); ok || err != nil {
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
jsonBytes, err := json.Marshal(response)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
return GetDeezerMetadata(resourceType, resourceID)
|
return GetDeezerMetadata(resourceType, resourceID)
|
||||||
default:
|
default:
|
||||||
response, err := getExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID)
|
response, err := getExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID)
|
||||||
@@ -2126,6 +2306,19 @@ func GetProviderMetadataJSON(providerID, resourceType, resourceID string) (strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getEnabledExtensionProviderMetadataResponse(providerID, resourceType, resourceID string) (map[string]interface{}, bool, error) {
|
||||||
|
manager := getExtensionManager()
|
||||||
|
ext, err := manager.GetExtension(providerID)
|
||||||
|
if err != nil || ext == nil || !ext.Enabled || !ext.Manifest.IsMetadataProvider() {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
response, err := getExtensionProviderMetadataResponse(providerID, resourceType, resourceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
return response, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
||||||
if trackID == "" {
|
if trackID == "" {
|
||||||
return "", fmt.Errorf("empty track ID")
|
return "", fmt.Errorf("empty track ID")
|
||||||
@@ -2329,37 +2522,7 @@ func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func errorResponse(msg string) (string, error) {
|
func errorResponse(msg string) (string, error) {
|
||||||
errorType := "unknown"
|
errorType := classifyDownloadErrorType(msg)
|
||||||
lowerMsg := strings.ToLower(msg)
|
|
||||||
|
|
||||||
if strings.Contains(lowerMsg, "isp blocking") ||
|
|
||||||
strings.Contains(lowerMsg, "try using vpn") ||
|
|
||||||
strings.Contains(lowerMsg, "change dns") {
|
|
||||||
errorType = "isp_blocked"
|
|
||||||
} else if strings.Contains(lowerMsg, "cancel") {
|
|
||||||
errorType = "cancelled"
|
|
||||||
} else if strings.Contains(lowerMsg, "permission") ||
|
|
||||||
strings.Contains(lowerMsg, "operation not permitted") ||
|
|
||||||
strings.Contains(lowerMsg, "access denied") ||
|
|
||||||
strings.Contains(lowerMsg, "failed to create file") ||
|
|
||||||
strings.Contains(lowerMsg, "failed to create directory") {
|
|
||||||
errorType = "permission"
|
|
||||||
} else if strings.Contains(lowerMsg, "not found") ||
|
|
||||||
strings.Contains(lowerMsg, "not available") ||
|
|
||||||
strings.Contains(lowerMsg, "no results") ||
|
|
||||||
strings.Contains(lowerMsg, "track not found") ||
|
|
||||||
strings.Contains(lowerMsg, "all services failed") {
|
|
||||||
errorType = "not_found"
|
|
||||||
} else if strings.Contains(lowerMsg, "rate limit") ||
|
|
||||||
strings.Contains(lowerMsg, "429") ||
|
|
||||||
strings.Contains(lowerMsg, "too many requests") {
|
|
||||||
errorType = "rate_limit"
|
|
||||||
} else if strings.Contains(lowerMsg, "network") ||
|
|
||||||
strings.Contains(lowerMsg, "connection") ||
|
|
||||||
strings.Contains(lowerMsg, "timeout") ||
|
|
||||||
strings.Contains(lowerMsg, "dial") {
|
|
||||||
errorType = "network"
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := DownloadResponse{
|
resp := DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
@@ -2370,6 +2533,61 @@ func errorResponse(msg string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func classifyDownloadErrorType(msg string) string {
|
||||||
|
lowerMsg := strings.ToLower(msg)
|
||||||
|
|
||||||
|
if strings.Contains(lowerMsg, "isp blocking") ||
|
||||||
|
strings.Contains(lowerMsg, "try using vpn") ||
|
||||||
|
strings.Contains(lowerMsg, "change dns") {
|
||||||
|
return "isp_blocked"
|
||||||
|
} else if strings.Contains(lowerMsg, "cancel") {
|
||||||
|
return "cancelled"
|
||||||
|
} else if strings.Contains(lowerMsg, "verify_required") ||
|
||||||
|
strings.Contains(lowerMsg, "verification_required") ||
|
||||||
|
strings.Contains(lowerMsg, "verification required") ||
|
||||||
|
strings.Contains(lowerMsg, "needs verification") ||
|
||||||
|
strings.Contains(lowerMsg, "session is not authenticated") ||
|
||||||
|
strings.Contains(lowerMsg, "signed session is not authenticated") ||
|
||||||
|
strings.Contains(lowerMsg, "unauthorized") ||
|
||||||
|
strings.Contains(lowerMsg, "precondition required") ||
|
||||||
|
messageHasHTTPStatusCode(lowerMsg, "401") ||
|
||||||
|
messageHasHTTPStatusCode(lowerMsg, "428") {
|
||||||
|
return "verification_required"
|
||||||
|
} else if strings.Contains(lowerMsg, "rate limit") ||
|
||||||
|
messageHasHTTPStatusCode(lowerMsg, "429") ||
|
||||||
|
strings.Contains(lowerMsg, "too many requests") {
|
||||||
|
return "rate_limit"
|
||||||
|
} else if strings.Contains(lowerMsg, "permission") ||
|
||||||
|
strings.Contains(lowerMsg, "operation not permitted") ||
|
||||||
|
strings.Contains(lowerMsg, "access denied") ||
|
||||||
|
strings.Contains(lowerMsg, "failed to create file") ||
|
||||||
|
strings.Contains(lowerMsg, "failed to create directory") {
|
||||||
|
return "permission"
|
||||||
|
} else if strings.Contains(lowerMsg, "not found") ||
|
||||||
|
strings.Contains(lowerMsg, "not available") ||
|
||||||
|
strings.Contains(lowerMsg, "no results") ||
|
||||||
|
strings.Contains(lowerMsg, "track not found") ||
|
||||||
|
strings.Contains(lowerMsg, "all services failed") {
|
||||||
|
return "not_found"
|
||||||
|
} else if strings.Contains(lowerMsg, "network") ||
|
||||||
|
strings.Contains(lowerMsg, "connection") ||
|
||||||
|
strings.Contains(lowerMsg, "timeout") ||
|
||||||
|
strings.Contains(lowerMsg, "dial") {
|
||||||
|
return "network"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageHasHTTPStatusCode(lowerMsg, code string) bool {
|
||||||
|
return strings.Contains(lowerMsg, "http "+code) ||
|
||||||
|
strings.Contains(lowerMsg, "http status "+code) ||
|
||||||
|
strings.Contains(lowerMsg, "status "+code) ||
|
||||||
|
strings.Contains(lowerMsg, code+" for ") ||
|
||||||
|
strings.Contains(lowerMsg, code+":") ||
|
||||||
|
strings.Contains(lowerMsg, code+";")
|
||||||
|
}
|
||||||
|
|
||||||
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
|
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
|
||||||
if coverURL == "" {
|
if coverURL == "" {
|
||||||
return fmt.Errorf("no cover URL provided")
|
return fmt.Errorf("no cover URL provided")
|
||||||
@@ -2522,8 +2740,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
|
|
||||||
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
|
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
|
||||||
|
|
||||||
// When search_online is true, search for metadata from internet using the
|
|
||||||
// configured metadata-provider priority.
|
|
||||||
if req.SearchOnline {
|
if req.SearchOnline {
|
||||||
found := false
|
found := false
|
||||||
|
|
||||||
@@ -2692,7 +2908,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isFlac {
|
if isFlac {
|
||||||
// Native Go FLAC metadata embedding.
|
|
||||||
// Only populate Metadata fields for selected update groups; empty/zero
|
// Only populate Metadata fields for selected update groups; empty/zero
|
||||||
// values cause EmbedMetadata's setComment() to skip those tags,
|
// values cause EmbedMetadata's setComment() to skip those tags,
|
||||||
// preserving whatever is already in the file.
|
// preserving whatever is already in the file.
|
||||||
@@ -2716,7 +2931,9 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
metadata.ISRC = req.ISRC
|
metadata.ISRC = req.ISRC
|
||||||
}
|
}
|
||||||
if req.shouldUpdateField("lyrics") {
|
if req.shouldUpdateField("lyrics") {
|
||||||
metadata.Lyrics = lyricsLRC
|
if req.lyricsEmbedEnabled() {
|
||||||
|
metadata.Lyrics = lyricsLRC
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if req.shouldUpdateField("extra") {
|
if req.shouldUpdateField("extra") {
|
||||||
metadata.Genre = req.Genre
|
metadata.Genre = req.Genre
|
||||||
@@ -2751,6 +2968,11 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
"method": "native",
|
"method": "native",
|
||||||
"success": true,
|
"success": true,
|
||||||
"enriched_metadata": enrichedMeta,
|
"enriched_metadata": enrichedMeta,
|
||||||
|
"lyrics": lyricsLRC,
|
||||||
|
"write_external_lrc": req.EmbedLyrics &&
|
||||||
|
req.shouldUpdateField("lyrics") &&
|
||||||
|
req.lyricsSidecarEnabled() &&
|
||||||
|
strings.TrimSpace(lyricsLRC) != "",
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(result)
|
jsonBytes, _ := json.Marshal(result)
|
||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
@@ -2766,6 +2988,10 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
|||||||
"lyrics": lyricsLRC,
|
"lyrics": lyricsLRC,
|
||||||
"enriched_metadata": enrichedMeta,
|
"enriched_metadata": enrichedMeta,
|
||||||
"metadata": ffmpegMetadata,
|
"metadata": ffmpegMetadata,
|
||||||
|
"write_external_lrc": req.EmbedLyrics &&
|
||||||
|
req.shouldUpdateField("lyrics") &&
|
||||||
|
req.lyricsSidecarEnabled() &&
|
||||||
|
strings.TrimSpace(lyricsLRC) != "",
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, _ := json.Marshal(result)
|
jsonBytes, _ := json.Marshal(result)
|
||||||
@@ -3061,7 +3287,7 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
||||||
req := GetPendingAuthRequest(extensionID)
|
req := ensureExtensionPendingAuthRequest(extensionID)
|
||||||
if req == nil {
|
if req == nil {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
@@ -3080,10 +3306,48 @@ func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureExtensionPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
||||||
|
extensionID = strings.TrimSpace(extensionID)
|
||||||
|
if extensionID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if req := GetPendingAuthRequest(extensionID); req != nil {
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := getExtensionManager()
|
||||||
|
ext, err := manager.GetExtension(extensionID)
|
||||||
|
if err != nil || ext == nil || !ext.Enabled || ext.Manifest == nil || ext.Manifest.SignedSession == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ext.ensureRuntimeReady(); err != nil || ext.runtime == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
config := signedSessionConfigWithDefaults(ext.Manifest.SignedSession)
|
||||||
|
if config.Namespace == "" || config.BaseURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if record, err := ext.runtime.loadSignedSession(config); err == nil {
|
||||||
|
record.SessionID = ""
|
||||||
|
record.SessionSecret = ""
|
||||||
|
record.ExpiresAt = ""
|
||||||
|
_ = ext.runtime.saveSignedSession(config, record)
|
||||||
|
}
|
||||||
|
ext.runtime.startSignedSessionVerification(config, "pending-auth-request")
|
||||||
|
return GetPendingAuthRequest(extensionID)
|
||||||
|
}
|
||||||
|
|
||||||
func SetExtensionAuthCodeByID(extensionID, authCode string) {
|
func SetExtensionAuthCodeByID(extensionID, authCode string) {
|
||||||
SetExtensionAuthCode(extensionID, authCode)
|
SetExtensionAuthCode(extensionID, authCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SetExtensionSessionGrantByID(extensionID, grant string) {
|
||||||
|
setPendingSignedSessionGrant(extensionID, grant)
|
||||||
|
}
|
||||||
|
|
||||||
func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) {
|
func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) {
|
||||||
var expiresAt time.Time
|
var expiresAt time.Time
|
||||||
if expiresIn > 0 {
|
if expiresIn > 0 {
|
||||||
@@ -3250,6 +3514,7 @@ func CustomSearchWithExtensionJSONWithRequestID(extensionID, query string, optio
|
|||||||
"album_artist": track.AlbumArtist,
|
"album_artist": track.AlbumArtist,
|
||||||
"duration_ms": track.DurationMS,
|
"duration_ms": track.DurationMS,
|
||||||
"images": track.ResolvedCoverURL(),
|
"images": track.ResolvedCoverURL(),
|
||||||
|
"preview_url": track.PreviewURL,
|
||||||
"release_date": track.ReleaseDate,
|
"release_date": track.ReleaseDate,
|
||||||
"track_number": track.TrackNumber,
|
"track_number": track.TrackNumber,
|
||||||
"total_tracks": track.TotalTracks,
|
"total_tracks": track.TotalTracks,
|
||||||
@@ -3315,6 +3580,8 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"extension_id": extensionID,
|
"extension_id": extensionID,
|
||||||
"name": result.Name,
|
"name": result.Name,
|
||||||
"cover_url": result.CoverURL,
|
"cover_url": result.CoverURL,
|
||||||
|
"header_image": result.HeaderImage,
|
||||||
|
"header_video": result.HeaderVideo,
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.Track != nil {
|
if result.Track != nil {
|
||||||
@@ -3326,6 +3593,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"album_artist": result.Track.AlbumArtist,
|
"album_artist": result.Track.AlbumArtist,
|
||||||
"duration_ms": result.Track.DurationMS,
|
"duration_ms": result.Track.DurationMS,
|
||||||
"images": result.Track.ResolvedCoverURL(),
|
"images": result.Track.ResolvedCoverURL(),
|
||||||
|
"preview_url": result.Track.PreviewURL,
|
||||||
"release_date": result.Track.ReleaseDate,
|
"release_date": result.Track.ReleaseDate,
|
||||||
"track_number": result.Track.TrackNumber,
|
"track_number": result.Track.TrackNumber,
|
||||||
"total_tracks": result.Track.TotalTracks,
|
"total_tracks": result.Track.TotalTracks,
|
||||||
@@ -3348,6 +3616,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"album_artist": track.AlbumArtist,
|
"album_artist": track.AlbumArtist,
|
||||||
"duration_ms": track.DurationMS,
|
"duration_ms": track.DurationMS,
|
||||||
"images": track.ResolvedCoverURL(),
|
"images": track.ResolvedCoverURL(),
|
||||||
|
"preview_url": track.PreviewURL,
|
||||||
"release_date": track.ReleaseDate,
|
"release_date": track.ReleaseDate,
|
||||||
"track_number": track.TrackNumber,
|
"track_number": track.TrackNumber,
|
||||||
"total_tracks": track.TotalTracks,
|
"total_tracks": track.TotalTracks,
|
||||||
@@ -3369,6 +3638,9 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"name": result.Album.Name,
|
"name": result.Album.Name,
|
||||||
"artists": result.Album.Artists,
|
"artists": result.Album.Artists,
|
||||||
"cover_url": result.Album.CoverURL,
|
"cover_url": result.Album.CoverURL,
|
||||||
|
"header_image": result.Album.HeaderImage,
|
||||||
|
"header_video": result.Album.HeaderVideo,
|
||||||
|
"audio_traits": result.Album.AudioTraits,
|
||||||
"release_date": result.Album.ReleaseDate,
|
"release_date": result.Album.ReleaseDate,
|
||||||
"total_tracks": result.Album.TotalTracks,
|
"total_tracks": result.Album.TotalTracks,
|
||||||
"album_type": result.Album.AlbumType,
|
"album_type": result.Album.AlbumType,
|
||||||
@@ -3382,6 +3654,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"name": result.Artist.Name,
|
"name": result.Artist.Name,
|
||||||
"image_url": result.Artist.ImageURL,
|
"image_url": result.Artist.ImageURL,
|
||||||
"header_image": result.Artist.HeaderImage,
|
"header_image": result.Artist.HeaderImage,
|
||||||
|
"header_video": result.Artist.HeaderVideo,
|
||||||
"listeners": result.Artist.Listeners,
|
"listeners": result.Artist.Listeners,
|
||||||
"provider_id": result.Artist.ProviderID,
|
"provider_id": result.Artist.ProviderID,
|
||||||
}
|
}
|
||||||
@@ -3441,6 +3714,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"album_artist": track.AlbumArtist,
|
"album_artist": track.AlbumArtist,
|
||||||
"duration_ms": track.DurationMS,
|
"duration_ms": track.DurationMS,
|
||||||
"images": track.ResolvedCoverURL(),
|
"images": track.ResolvedCoverURL(),
|
||||||
|
"preview_url": track.PreviewURL,
|
||||||
"release_date": track.ReleaseDate,
|
"release_date": track.ReleaseDate,
|
||||||
"track_number": track.TrackNumber,
|
"track_number": track.TrackNumber,
|
||||||
"total_tracks": track.TotalTracks,
|
"total_tracks": track.TotalTracks,
|
||||||
@@ -3676,13 +3950,29 @@ func GetStoreCategoriesJSON() (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildStoreExtensionDestPath(destDir, extensionID string) (string, error) {
|
func storeExtensionPackageSuffix(downloadURL string) string {
|
||||||
|
rawPath := downloadURL
|
||||||
|
if parsed, err := url.Parse(downloadURL); err == nil {
|
||||||
|
rawPath = parsed.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
lowerPath := strings.ToLower(rawPath)
|
||||||
|
if strings.HasSuffix(lowerPath, ".sflx") {
|
||||||
|
return ".sflx"
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(lowerPath, ".spotiflac-ext") {
|
||||||
|
return ".spotiflac-ext"
|
||||||
|
}
|
||||||
|
return ".spotiflac-ext"
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStoreExtensionDestPath(destDir, extensionID, downloadURL string) (string, error) {
|
||||||
if strings.TrimSpace(extensionID) == "" {
|
if strings.TrimSpace(extensionID) == "" {
|
||||||
return "", fmt.Errorf("invalid extension id")
|
return "", fmt.Errorf("invalid extension id")
|
||||||
}
|
}
|
||||||
|
|
||||||
safeExtensionID := sanitizeFilename(extensionID)
|
safeExtensionID := sanitizeFilename(extensionID)
|
||||||
return filepath.Join(destDir, safeExtensionID+".spotiflac-ext"), nil
|
return filepath.Join(destDir, safeExtensionID+storeExtensionPackageSuffix(downloadURL)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
||||||
@@ -3691,7 +3981,12 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
|||||||
return "", fmt.Errorf("extension store not initialized")
|
return "", fmt.Errorf("extension store not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
destPath, err := buildStoreExtensionDestPath(destDir, extensionID)
|
ext, err := store.findExtension(extensionID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
destPath, err := buildStoreExtensionDestPath(destDir, extensionID, ext.getDownloadURL())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -3756,9 +4051,12 @@ func callExtensionFunctionJSONWithRequestID(extensionID, functionName string, ti
|
|||||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||||
return extension.%s();
|
return extension.%s();
|
||||||
}
|
}
|
||||||
|
if (typeof %s === 'function') {
|
||||||
|
return %s();
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
})()
|
})()
|
||||||
`, functionName, functionName)
|
`, functionName, functionName, functionName, functionName)
|
||||||
|
|
||||||
jsStartedAt := time.Now()
|
jsStartedAt := time.Now()
|
||||||
result, err := RunWithTimeoutContextAndRecover(requestCtx, vm, script, timeout)
|
result, err := RunWithTimeoutContextAndRecover(requestCtx, vm, script, timeout)
|
||||||
|
|||||||
@@ -11,6 +11,64 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
|
||||||
|
got := classifyDownloadErrorType("All providers failed. Last error: HTTP status 429: too many requests")
|
||||||
|
if got != "rate_limit" {
|
||||||
|
t.Fatalf("expected rate_limit, got %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
responseJSON, err := errorResponse("All services failed. Last error: rate limit exceeded")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("errorResponse returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response DownloadResponse
|
||||||
|
if err := json.Unmarshal([]byte(responseJSON), &response); err != nil {
|
||||||
|
t.Fatalf("invalid response JSON: %v", err)
|
||||||
|
}
|
||||||
|
if response.ErrorType != "rate_limit" {
|
||||||
|
t.Fatalf("expected rate_limit response, got %q", response.ErrorType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"HTTP 401 for /tickets",
|
||||||
|
"HTTP status 428: precondition required",
|
||||||
|
"Verification required",
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := classifyDownloadErrorType(tc); got != "verification_required" {
|
||||||
|
t.Fatalf("classifyDownloadErrorType(%q) = %q, want verification_required", tc, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetProviderMetadataPrefersEnabledDeezerExtension(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
|
||||||
|
t.Fatalf("InitExtensionSystem: %v", err)
|
||||||
|
}
|
||||||
|
CleanupExtensions()
|
||||||
|
defer CleanupExtensions()
|
||||||
|
|
||||||
|
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
|
||||||
|
ext.ID = "deezer"
|
||||||
|
ext.Manifest.Name = "deezer"
|
||||||
|
manager := getExtensionManager()
|
||||||
|
manager.mu.Lock()
|
||||||
|
manager.extensions = map[string]*loadedExtension{ext.ID: ext}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
jsonText, err := GetProviderMetadataJSON("deezer", "album", "201")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetProviderMetadataJSON deezer album: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(jsonText, "album-track") {
|
||||||
|
t.Fatalf("expected enabled deezer extension metadata, got %s", jsonText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
dataDir := filepath.Join(dir, "data")
|
dataDir := filepath.Join(dir, "data")
|
||||||
@@ -370,10 +428,25 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
|||||||
if catsJSON, err := GetStoreCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
|
if catsJSON, err := GetStoreCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
|
||||||
t.Fatalf("GetStoreCategoriesJSON = %q/%v", catsJSON, err)
|
t.Fatalf("GetStoreCategoriesJSON = %q/%v", catsJSON, err)
|
||||||
}
|
}
|
||||||
if dest, err := buildStoreExtensionDestPath(dir, "coverage/ext"); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
|
if dest, err := buildStoreExtensionDestPath(
|
||||||
|
dir,
|
||||||
|
"coverage/ext",
|
||||||
|
"https://registry.example.com/coverage.spotiflac-ext",
|
||||||
|
); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
|
||||||
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
|
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
|
||||||
}
|
}
|
||||||
if _, err := buildStoreExtensionDestPath(dir, " "); err == nil {
|
if dest, err := buildStoreExtensionDestPath(
|
||||||
|
dir,
|
||||||
|
"coverage/ext",
|
||||||
|
"https://registry.example.com/coverage.sflx",
|
||||||
|
); err != nil || !strings.HasSuffix(dest, ".sflx") {
|
||||||
|
t.Fatalf("buildStoreExtensionDestPath sflx = %q/%v", dest, err)
|
||||||
|
}
|
||||||
|
if _, err := buildStoreExtensionDestPath(
|
||||||
|
dir,
|
||||||
|
" ",
|
||||||
|
"https://registry.example.com/coverage.sflx",
|
||||||
|
); err == nil {
|
||||||
t.Fatal("expected invalid extension id")
|
t.Fatal("expected invalid extension id")
|
||||||
}
|
}
|
||||||
if err := ClearStoreCacheJSON(); err != nil {
|
if err := ClearStoreCacheJSON(); err != nil {
|
||||||
|
|||||||
@@ -407,6 +407,90 @@ func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSelectBestReEnrichTrackRejectsMismatchedSearchResults(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackName: "Song Title",
|
||||||
|
ArtistName: "Artist Name",
|
||||||
|
AlbumName: "Album Name",
|
||||||
|
DurationMs: 180000,
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := []ExtTrackMetadata{
|
||||||
|
{
|
||||||
|
ID: "wrong-rich-metadata",
|
||||||
|
Name: "Different Song",
|
||||||
|
Artists: "Different Artist",
|
||||||
|
AlbumName: "Album Name",
|
||||||
|
DurationMS: 180000,
|
||||||
|
ReleaseDate: "2024-03-09",
|
||||||
|
TrackNumber: 4,
|
||||||
|
DiscNumber: 1,
|
||||||
|
ISRC: "WRONG1234567",
|
||||||
|
ProviderID: "deezer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if best := selectBestReEnrichTrack(req, tracks); best != nil {
|
||||||
|
t.Fatalf("selected track = %q, want no match", best.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectBestReEnrichTrackAllowsExactISRCDespiteMetadataMismatch(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackName: "Song Title",
|
||||||
|
ArtistName: "Artist Name",
|
||||||
|
ISRC: "USRC17607839",
|
||||||
|
DurationMs: 999999000,
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := []ExtTrackMetadata{
|
||||||
|
{
|
||||||
|
ID: "same-isrc",
|
||||||
|
Name: "Different Song",
|
||||||
|
Artists: "Different Artist",
|
||||||
|
DurationMS: 180000,
|
||||||
|
ISRC: "USRC17607839",
|
||||||
|
ProviderID: "deezer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
best := selectBestReEnrichTrack(req, tracks)
|
||||||
|
if best == nil {
|
||||||
|
t.Fatal("expected exact ISRC candidate to be selected")
|
||||||
|
}
|
||||||
|
if best.ID != "same-isrc" {
|
||||||
|
t.Fatalf("selected track = %q, want exact ISRC candidate", best.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectBestReEnrichTrackPlaceholderFallsBackToAlbum(t *testing.T) {
|
||||||
|
req := reEnrichRequest{
|
||||||
|
TrackName: "Unknown Title",
|
||||||
|
ArtistName: "Unknown Artist",
|
||||||
|
AlbumName: "Harry Styles",
|
||||||
|
DurationMs: 180000,
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := []ExtTrackMetadata{
|
||||||
|
{
|
||||||
|
ID: "album-match",
|
||||||
|
Name: "Sign of the Times",
|
||||||
|
Artists: "Harry Styles",
|
||||||
|
AlbumName: "Harry Styles",
|
||||||
|
DurationMS: 180000,
|
||||||
|
ProviderID: "deezer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
best := selectBestReEnrichTrack(req, tracks)
|
||||||
|
if best == nil {
|
||||||
|
t.Fatal("expected album-matching candidate to be selected when title/artist are placeholders")
|
||||||
|
}
|
||||||
|
if best.ID != "album-match" {
|
||||||
|
t.Fatalf("selected track = %q, want album-match", best.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
||||||
req := reEnrichRequest{
|
req := reEnrichRequest{
|
||||||
TrackName: "Song",
|
TrackName: "Song",
|
||||||
|
|||||||
+140
-13
@@ -8,12 +8,16 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
extensionHealthDefaultTimeout = 4 * time.Second
|
extensionHealthDefaultTimeout = 4 * time.Second
|
||||||
extensionHealthMaxBodyBytes = 64 * 1024
|
extensionHealthMaxBodyBytes = 64 * 1024
|
||||||
|
extensionHealthDefaultCache = 10 * time.Minute
|
||||||
|
extensionHealthMinCache = 60 * time.Second
|
||||||
|
extensionHealthUnknownCache = 2 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
type ExtensionHealthResult struct {
|
type ExtensionHealthResult struct {
|
||||||
@@ -38,6 +42,16 @@ type ExtensionHealthCheckResult struct {
|
|||||||
CheckedAt string `json:"checked_at"`
|
CheckedAt string `json:"checked_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type cachedExtensionHealthResult struct {
|
||||||
|
result ExtensionHealthResult
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
extensionHealthCacheMu sync.Mutex
|
||||||
|
extensionHealthCache = map[string]cachedExtensionHealthResult{}
|
||||||
|
)
|
||||||
|
|
||||||
func CheckExtensionHealthJSON(extensionID string) (string, error) {
|
func CheckExtensionHealthJSON(extensionID string) (string, error) {
|
||||||
manager := getExtensionManager()
|
manager := getExtensionManager()
|
||||||
ext, err := manager.GetExtension(extensionID)
|
ext, err := manager.GetExtension(extensionID)
|
||||||
@@ -46,6 +60,7 @@ func CheckExtensionHealthJSON(extensionID string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result := CheckExtensionHealth(ext)
|
result := CheckExtensionHealth(ext)
|
||||||
|
cacheExtensionHealthResult(ext, result)
|
||||||
bytes, err := json.Marshal(result)
|
bytes, err := json.Marshal(result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -53,6 +68,53 @@ func CheckExtensionHealthJSON(extensionID string) (string, error) {
|
|||||||
return string(bytes), nil
|
return string(bytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
|
||||||
|
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
|
||||||
|
return CheckExtensionHealth(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := strings.TrimSpace(ext.ID)
|
||||||
|
if cacheKey == "" {
|
||||||
|
return CheckExtensionHealth(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
extensionHealthCacheMu.Lock()
|
||||||
|
cached, ok := extensionHealthCache[cacheKey]
|
||||||
|
if ok && now.Before(cached.expiresAt) {
|
||||||
|
extensionHealthCacheMu.Unlock()
|
||||||
|
return cached.result
|
||||||
|
}
|
||||||
|
extensionHealthCacheMu.Unlock()
|
||||||
|
|
||||||
|
result := CheckExtensionHealth(ext)
|
||||||
|
cacheExtensionHealthResult(ext, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheExtensionHealthResult(ext *loadedExtension, result ExtensionHealthResult) {
|
||||||
|
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheKey := strings.TrimSpace(ext.ID)
|
||||||
|
if cacheKey == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth)
|
||||||
|
if result.Status == "unknown" && ttl > extensionHealthUnknownCache {
|
||||||
|
ttl = extensionHealthUnknownCache
|
||||||
|
}
|
||||||
|
|
||||||
|
extensionHealthCacheMu.Lock()
|
||||||
|
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
|
||||||
|
result: result,
|
||||||
|
expiresAt: time.Now().Add(ttl),
|
||||||
|
}
|
||||||
|
extensionHealthCacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
|
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
result := ExtensionHealthResult{
|
result := ExtensionHealthResult{
|
||||||
@@ -98,6 +160,23 @@ func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extensionHealthCacheTTL(checks []ExtensionHealthCheck) time.Duration {
|
||||||
|
ttl := extensionHealthDefaultCache
|
||||||
|
for _, check := range checks {
|
||||||
|
if check.CacheTTLSeconds <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second
|
||||||
|
if checkTTL < extensionHealthMinCache {
|
||||||
|
checkTTL = extensionHealthMinCache
|
||||||
|
}
|
||||||
|
if checkTTL < ttl {
|
||||||
|
ttl = checkTTL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ttl
|
||||||
|
}
|
||||||
|
|
||||||
func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthCheck) ExtensionHealthCheckResult {
|
func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthCheck) ExtensionHealthCheckResult {
|
||||||
method := strings.ToUpper(strings.TrimSpace(check.Method))
|
method := strings.ToUpper(strings.TrimSpace(check.Method))
|
||||||
if method == "" {
|
if method == "" {
|
||||||
@@ -168,7 +247,11 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC
|
|||||||
resp, err := NewMetadataHTTPClient(timeout).Do(req)
|
resp, err := NewMetadataHTTPClient(timeout).Do(req)
|
||||||
result.LatencyMs = time.Since(start).Milliseconds()
|
result.LatencyMs = time.Since(start).Milliseconds()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Status = "offline"
|
if isTransientExtensionHealthError(err) {
|
||||||
|
result.Status = "unknown"
|
||||||
|
} else {
|
||||||
|
result.Status = "offline"
|
||||||
|
}
|
||||||
result.Error = err.Error()
|
result.Error = err.Error()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -204,6 +287,10 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isTransientExtensionHealthError(err error) bool {
|
||||||
|
return isTransientNetworkError(err) || isConnectivityFailure(err)
|
||||||
|
}
|
||||||
|
|
||||||
func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string) {
|
func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string) {
|
||||||
if len(strings.TrimSpace(string(body))) == 0 {
|
if len(strings.TrimSpace(string(body))) == 0 {
|
||||||
return "online", ""
|
return "online", ""
|
||||||
@@ -229,6 +316,9 @@ func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string
|
|||||||
case "degraded", "partial", "warning", "warn":
|
case "degraded", "partial", "warning", "warn":
|
||||||
return "degraded", rawStatus
|
return "degraded", rawStatus
|
||||||
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
||||||
|
if isTransientHealthStatusMessage(string(body)) {
|
||||||
|
return "unknown", rawStatus
|
||||||
|
}
|
||||||
return "offline", rawStatus
|
return "offline", rawStatus
|
||||||
default:
|
default:
|
||||||
return "online", rawStatus
|
return "online", rawStatus
|
||||||
@@ -269,42 +359,53 @@ func classifyExtensionHealthService(payload map[string]interface{}, serviceKey s
|
|||||||
|
|
||||||
rawStatus, hasStatus := service["status"]
|
rawStatus, hasStatus := service["status"]
|
||||||
okValue, hasOK := service["ok"].(bool)
|
okValue, hasOK := service["ok"].(bool)
|
||||||
|
joinedMessage := strings.Join(messageParts, ": ")
|
||||||
|
transient := isTransientHealthStatusMessage(detail) ||
|
||||||
|
isTransientHealthStatusMessage(errText) ||
|
||||||
|
isTransientHealthStatusMessage(label)
|
||||||
|
|
||||||
if statusCode, ok := healthNumber(rawStatus); ok {
|
if statusCode, ok := healthNumber(rawStatus); ok {
|
||||||
if statusCode >= 200 && statusCode < 300 {
|
if statusCode >= 200 && statusCode < 300 {
|
||||||
return "online", strings.Join(messageParts, ": "), true
|
return "online", joinedMessage, true
|
||||||
}
|
}
|
||||||
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
|
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
|
||||||
return "degraded", strings.Join(messageParts, ": "), true
|
return "degraded", joinedMessage, true
|
||||||
}
|
}
|
||||||
if statusCode == http.StatusInternalServerError && hasOK && okValue {
|
if statusCode == http.StatusInternalServerError && hasOK && okValue {
|
||||||
return "online", strings.Join(messageParts, ": "), true
|
return "online", joinedMessage, true
|
||||||
}
|
}
|
||||||
return "offline", strings.Join(messageParts, ": "), true
|
if transient || isTransientHealthStatusCode(statusCode) {
|
||||||
|
return "unknown", joinedMessage, true
|
||||||
|
}
|
||||||
|
return "offline", joinedMessage, true
|
||||||
}
|
}
|
||||||
|
|
||||||
if isExtensionHealthAuthRequired(detail) {
|
if isExtensionHealthAuthRequired(detail) {
|
||||||
return "degraded", strings.Join(messageParts, ": "), true
|
return "degraded", joinedMessage, true
|
||||||
|
}
|
||||||
|
if transient {
|
||||||
|
return "unknown", joinedMessage, true
|
||||||
}
|
}
|
||||||
if hasOK {
|
if hasOK {
|
||||||
if okValue {
|
if okValue {
|
||||||
return "online", strings.Join(messageParts, ": "), true
|
return "online", joinedMessage, true
|
||||||
}
|
}
|
||||||
return "offline", strings.Join(messageParts, ": "), true
|
return "offline", joinedMessage, true
|
||||||
}
|
}
|
||||||
if !hasStatus {
|
if !hasStatus {
|
||||||
return "unknown", strings.Join(messageParts, ": "), true
|
return "unknown", joinedMessage, true
|
||||||
}
|
}
|
||||||
|
|
||||||
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
|
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
|
||||||
switch statusString {
|
switch statusString {
|
||||||
case "ok", "up", "online", "healthy", "operational":
|
case "ok", "up", "online", "healthy", "operational":
|
||||||
return "online", strings.Join(messageParts, ": "), true
|
return "online", joinedMessage, true
|
||||||
case "degraded", "partial", "warning", "warn":
|
case "degraded", "partial", "warning", "warn":
|
||||||
return "degraded", strings.Join(messageParts, ": "), true
|
return "degraded", joinedMessage, true
|
||||||
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
||||||
return "offline", strings.Join(messageParts, ": "), true
|
return "offline", joinedMessage, true
|
||||||
default:
|
default:
|
||||||
return "unknown", strings.Join(messageParts, ": "), true
|
return "unknown", joinedMessage, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +418,32 @@ func isExtensionHealthAuthRequired(detail string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isTransientHealthStatusMessage(text string) bool {
|
||||||
|
t := strings.ToLower(strings.TrimSpace(text))
|
||||||
|
if t == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(t, "context deadline exceeded") ||
|
||||||
|
strings.Contains(t, "deadline exceeded") ||
|
||||||
|
strings.Contains(t, "timeout") ||
|
||||||
|
strings.Contains(t, "timed out") ||
|
||||||
|
strings.Contains(t, "temporarily unavailable") ||
|
||||||
|
strings.Contains(t, "try again")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTransientHealthStatusCode(code int) bool {
|
||||||
|
switch code {
|
||||||
|
case http.StatusRequestTimeout,
|
||||||
|
http.StatusTooManyRequests,
|
||||||
|
http.StatusBadGateway,
|
||||||
|
http.StatusServiceUnavailable,
|
||||||
|
http.StatusGatewayTimeout:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func healthNumber(value interface{}) (int, bool) {
|
func healthNumber(value interface{}) (int, bool) {
|
||||||
switch v := value.(type) {
|
switch v := value.(type) {
|
||||||
case float64:
|
case float64:
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -27,6 +29,12 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) {
|
|||||||
if !isExtensionHealthAuthRequired(" unauthorized ") {
|
if !isExtensionHealthAuthRequired(" unauthorized ") {
|
||||||
t.Fatal("expected auth required")
|
t.Fatal("expected auth required")
|
||||||
}
|
}
|
||||||
|
if !isTransientExtensionHealthError(context.DeadlineExceeded) || !isTransientExtensionHealthError(&net.DNSError{IsTimeout: true}) {
|
||||||
|
t.Fatal("expected timeout health errors to be transient")
|
||||||
|
}
|
||||||
|
if !isTransientExtensionHealthError(&net.DNSError{IsNotFound: true}) {
|
||||||
|
t.Fatal("expected health transport lookup errors to be indeterminate")
|
||||||
|
}
|
||||||
|
|
||||||
if result := CheckExtensionHealth(nil); result.Status != "offline" {
|
if result := CheckExtensionHealth(nil); result.Status != "offline" {
|
||||||
t.Fatalf("nil health = %#v", result)
|
t.Fatalf("nil health = %#v", result)
|
||||||
|
|||||||
@@ -44,18 +44,24 @@ func compareVersions(v1, v2 string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isExtensionPackagePath(filePath string) bool {
|
||||||
|
lowerPath := strings.ToLower(filePath)
|
||||||
|
return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx")
|
||||||
|
}
|
||||||
|
|
||||||
type loadedExtension struct {
|
type loadedExtension struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Manifest *ExtensionManifest `json:"manifest"`
|
Manifest *ExtensionManifest `json:"manifest"`
|
||||||
VM *goja.Runtime `json:"-"`
|
VM *goja.Runtime `json:"-"`
|
||||||
VMMu sync.Mutex `json:"-"`
|
VMMu sync.Mutex `json:"-"`
|
||||||
runtime *extensionRuntime
|
runtime *extensionRuntime
|
||||||
initialized bool
|
indexProgram *goja.Program
|
||||||
Enabled bool `json:"enabled"`
|
initialized bool
|
||||||
Error string `json:"error,omitempty"`
|
Enabled bool `json:"enabled"`
|
||||||
DataDir string `json:"data_dir"`
|
Error string `json:"error,omitempty"`
|
||||||
SourceDir string `json:"source_dir"`
|
DataDir string `json:"data_dir"`
|
||||||
IconPath string `json:"icon_path"`
|
SourceDir string `json:"source_dir"`
|
||||||
|
IconPath string `json:"icon_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
||||||
@@ -118,7 +124,11 @@ func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type extensionManager struct {
|
type extensionManager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
// mutationMu serializes install/upgrade/remove (heavy FS + goja VM
|
||||||
|
// teardown/reload), which are not safe to run concurrently. Acquired before
|
||||||
|
// m.mu; "*Locked" helpers assume it is held.
|
||||||
|
mutationMu sync.Mutex
|
||||||
extensions map[string]*loadedExtension
|
extensions map[string]*loadedExtension
|
||||||
extensionsDir string
|
extensionsDir string
|
||||||
dataDir string
|
dataDir string
|
||||||
@@ -156,8 +166,14 @@ func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
|
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
m.mutationMu.Lock()
|
||||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
defer m.mutationMu.Unlock()
|
||||||
|
return m.loadExtensionFromFileLocked(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *extensionManager) loadExtensionFromFileLocked(filePath string) (*loadedExtension, error) {
|
||||||
|
if !isExtensionPackagePath(filePath) {
|
||||||
|
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
|
||||||
}
|
}
|
||||||
|
|
||||||
zipReader, err := zip.OpenReader(filePath)
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
@@ -212,7 +228,7 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
|||||||
if exists {
|
if exists {
|
||||||
versionCompare := compareVersions(manifest.Version, existingVersion)
|
versionCompare := compareVersions(manifest.Version, existingVersion)
|
||||||
if versionCompare > 0 {
|
if versionCompare > 0 {
|
||||||
return m.UpgradeExtension(filePath)
|
return m.upgradeExtensionLocked(filePath)
|
||||||
} else if versionCompare == 0 {
|
} else if versionCompare == 0 {
|
||||||
return nil, fmt.Errorf("extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
return nil, fmt.Errorf("extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
||||||
} else {
|
} else {
|
||||||
@@ -296,6 +312,7 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
|||||||
func initializeVMLocked(ext *loadedExtension) error {
|
func initializeVMLocked(ext *loadedExtension) error {
|
||||||
ext.VM = nil
|
ext.VM = nil
|
||||||
ext.runtime = nil
|
ext.runtime = nil
|
||||||
|
ext.indexProgram = nil
|
||||||
ext.initialized = false
|
ext.initialized = false
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
ext.VM = vm
|
ext.VM = vm
|
||||||
@@ -305,6 +322,11 @@ func initializeVMLocked(ext *loadedExtension) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read index.js: %w", err)
|
return fmt.Errorf("failed to read index.js: %w", err)
|
||||||
}
|
}
|
||||||
|
indexProgram, err := goja.Compile(indexPath, string(jsCode), false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to compile extension code: %w", err)
|
||||||
|
}
|
||||||
|
ext.indexProgram = indexProgram
|
||||||
|
|
||||||
runtime := newExtensionRuntime(ext)
|
runtime := newExtensionRuntime(ext)
|
||||||
ext.runtime = runtime
|
ext.runtime = runtime
|
||||||
@@ -331,7 +353,7 @@ func initializeVMLocked(ext *loadedExtension) error {
|
|||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
_, err = vm.RunString(string(jsCode))
|
_, err = vm.RunProgram(indexProgram)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to execute extension code: %w", err)
|
return fmt.Errorf("failed to execute extension code: %w", err)
|
||||||
}
|
}
|
||||||
@@ -346,10 +368,17 @@ func initializeVMLocked(ext *loadedExtension) error {
|
|||||||
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
|
func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensionRuntime, error) {
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
|
|
||||||
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
indexProgram := ext.indexProgram
|
||||||
jsCode, err := os.ReadFile(indexPath)
|
if indexProgram == nil {
|
||||||
if err != nil {
|
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
||||||
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
|
jsCode, err := os.ReadFile(indexPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
|
||||||
|
}
|
||||||
|
indexProgram, err = goja.Compile(indexPath, string(jsCode), false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to compile extension code: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime := &extensionRuntime{
|
runtime := &extensionRuntime{
|
||||||
@@ -392,7 +421,7 @@ func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensio
|
|||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
if _, err := vm.RunString(string(jsCode)); err != nil {
|
if _, err := vm.RunProgram(indexProgram); err != nil {
|
||||||
runtime.closeStorageFlusher()
|
runtime.closeStorageFlusher()
|
||||||
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
|
return nil, nil, fmt.Errorf("failed to execute extension code: %w", err)
|
||||||
}
|
}
|
||||||
@@ -663,7 +692,7 @@ func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
|||||||
loaded = append(loaded, ext.ID)
|
loaded = append(loaded, ext.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
|
} else if isExtensionPackagePath(entry.Name()) {
|
||||||
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
|
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
||||||
@@ -736,6 +765,9 @@ func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedEx
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) RemoveExtension(extensionID string) error {
|
func (m *extensionManager) RemoveExtension(extensionID string) error {
|
||||||
|
m.mutationMu.Lock()
|
||||||
|
defer m.mutationMu.Unlock()
|
||||||
|
|
||||||
ext, err := m.GetExtension(extensionID)
|
ext, err := m.GetExtension(extensionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -756,8 +788,14 @@ func (m *extensionManager) RemoveExtension(extensionID string) error {
|
|||||||
|
|
||||||
// Only allows upgrades (new version > current version), not downgrades
|
// Only allows upgrades (new version > current version), not downgrades
|
||||||
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
|
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
m.mutationMu.Lock()
|
||||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
defer m.mutationMu.Unlock()
|
||||||
|
return m.upgradeExtensionLocked(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *extensionManager) upgradeExtensionLocked(filePath string) (*loadedExtension, error) {
|
||||||
|
if !isExtensionPackagePath(filePath) {
|
||||||
|
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
|
||||||
}
|
}
|
||||||
|
|
||||||
zipReader, err := zip.OpenReader(filePath)
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
@@ -905,8 +943,8 @@ type ExtensionUpgradeInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
if !isExtensionPackagePath(filePath) {
|
||||||
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 or .sflx file")
|
||||||
}
|
}
|
||||||
|
|
||||||
zipReader, err := zip.OpenReader(filePath)
|
zipReader, err := zip.OpenReader(filePath)
|
||||||
@@ -1151,14 +1189,16 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
|
|||||||
|
|
||||||
// Merge extension return values onto the top-level JSON object so Flutter can read
|
// 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.
|
// message, open_auth_url, setting_updates without unwrapping a nested "result" key.
|
||||||
|
actionNameLiteral := strconv.Quote(actionName)
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
var actionName = %s;
|
||||||
try {
|
function runAction(fn) {
|
||||||
var result = extension.%s();
|
try {
|
||||||
if (result && typeof result.then === 'function') {
|
var result = fn();
|
||||||
return { success: true, pending: true, message: 'Action started' };
|
if (result && typeof result.then === 'function') {
|
||||||
}
|
return { success: true, pending: true, message: 'Action started' };
|
||||||
|
}
|
||||||
if (result !== null && result !== undefined && typeof result === 'object') {
|
if (result !== null && result !== undefined && typeof result === 'object') {
|
||||||
var isArr = false;
|
var isArr = false;
|
||||||
if (typeof Array !== 'undefined' && Array.isArray) {
|
if (typeof Array !== 'undefined' && Array.isArray) {
|
||||||
@@ -1173,13 +1213,19 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { success: true, result: result };
|
return { success: true, result: result };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: e.toString() };
|
return { success: false, error: e.toString() };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (typeof extension !== 'undefined' && extension && typeof extension[actionName] === 'function') {
|
||||||
return { success: false, error: 'Action function not found: %s' };
|
return runAction(function() { return extension[actionName](); });
|
||||||
})()
|
}
|
||||||
`, actionName, actionName, actionName)
|
if (actionName === 'completeGrant' && typeof session !== 'undefined' && session && typeof session.completeGrant === 'function') {
|
||||||
|
return runAction(function() { return session.completeGrant(); });
|
||||||
|
}
|
||||||
|
return { success: false, error: 'Action function not found: ' + actionName };
|
||||||
|
})()
|
||||||
|
`, actionNameLiteral)
|
||||||
|
|
||||||
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
|
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -113,28 +114,49 @@ type ExtensionHealthCheck struct {
|
|||||||
Required bool `json:"required,omitempty"`
|
Required bool `json:"required,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SignedSessionEndpoints struct {
|
||||||
|
Bootstrap string `json:"bootstrap,omitempty"`
|
||||||
|
Challenge string `json:"challenge,omitempty"`
|
||||||
|
Exchange string `json:"exchange,omitempty"`
|
||||||
|
Refresh string `json:"refresh,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignedSessionConfig struct {
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
BaseURL string `json:"baseUrl"`
|
||||||
|
AppVersion string `json:"appVersion,omitempty"`
|
||||||
|
Platform string `json:"platform,omitempty"`
|
||||||
|
CallbackURL string `json:"callbackUrl,omitempty"`
|
||||||
|
SchemeLabel string `json:"schemeLabel,omitempty"`
|
||||||
|
HeaderPrefix string `json:"headerPrefix,omitempty"`
|
||||||
|
TimeWindowSeconds int `json:"timeWindowSeconds,omitempty"`
|
||||||
|
Endpoints SignedSessionEndpoints `json:"endpoints,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ExtensionManifest struct {
|
type ExtensionManifest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Homepage string `json:"homepage,omitempty"`
|
Homepage string `json:"homepage,omitempty"`
|
||||||
Icon string `json:"icon,omitempty"`
|
Icon string `json:"icon,omitempty"`
|
||||||
Types []ExtensionType `json:"type"`
|
Types []ExtensionType `json:"type"`
|
||||||
Permissions ExtensionPermissions `json:"permissions"`
|
Permissions ExtensionPermissions `json:"permissions"`
|
||||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||||
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
||||||
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
|
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
|
||||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||||
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
|
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
|
||||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
SignedSession *SignedSessionConfig `json:"signedSession,omitempty"`
|
||||||
|
RequiredRuntimeFeatures []string `json:"requiredRuntimeFeatures,omitempty"`
|
||||||
|
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ManifestValidationError struct {
|
type ManifestValidationError struct {
|
||||||
@@ -200,7 +222,6 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select type requires options
|
|
||||||
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
||||||
return &ManifestValidationError{
|
return &ManifestValidationError{
|
||||||
Field: fmt.Sprintf("settings[%d].options", i),
|
Field: fmt.Sprintf("settings[%d].options", i),
|
||||||
@@ -238,6 +259,26 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.SignedSession != nil {
|
||||||
|
if strings.TrimSpace(m.SignedSession.Namespace) == "" {
|
||||||
|
return &ManifestValidationError{Field: "signedSession.namespace", Message: "namespace is required"}
|
||||||
|
}
|
||||||
|
baseURL := strings.TrimSpace(m.SignedSession.BaseURL)
|
||||||
|
if baseURL == "" {
|
||||||
|
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is required"}
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(strings.ToLower(baseURL), "https://") {
|
||||||
|
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl must use https"}
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(baseURL)
|
||||||
|
if err != nil || parsed.Hostname() == "" {
|
||||||
|
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl is invalid"}
|
||||||
|
}
|
||||||
|
if !m.IsDomainAllowed(parsed.Hostname()) {
|
||||||
|
return &ManifestValidationError{Field: "signedSession.baseUrl", Message: "baseUrl host must be listed in permissions.network"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+535
-113
@@ -22,8 +22,14 @@ type ExtTrackMetadata struct {
|
|||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
AlbumName string `json:"album_name"`
|
AlbumName string `json:"album_name"`
|
||||||
AlbumArtist string `json:"album_artist,omitempty"`
|
AlbumArtist string `json:"album_artist,omitempty"`
|
||||||
|
AlbumID string `json:"album_id,omitempty"`
|
||||||
|
AlbumURL string `json:"album_url,omitempty"`
|
||||||
|
ArtistID string `json:"artist_id,omitempty"`
|
||||||
|
ArtistURL string `json:"artist_url,omitempty"`
|
||||||
|
ExternalURL string `json:"external_urls,omitempty"`
|
||||||
DurationMS int `json:"duration_ms"`
|
DurationMS int `json:"duration_ms"`
|
||||||
CoverURL string `json:"cover_url,omitempty"`
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
|
PreviewURL string `json:"preview_url,omitempty"`
|
||||||
Images string `json:"images,omitempty"`
|
Images string `json:"images,omitempty"`
|
||||||
ReleaseDate string `json:"release_date,omitempty"`
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
TrackNumber int `json:"track_number,omitempty"`
|
TrackNumber int `json:"track_number,omitempty"`
|
||||||
@@ -63,9 +69,12 @@ type ExtAlbumMetadata struct {
|
|||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
ArtistID string `json:"artist_id,omitempty"`
|
ArtistID string `json:"artist_id,omitempty"`
|
||||||
CoverURL string `json:"cover_url,omitempty"`
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
|
HeaderImage string `json:"header_image,omitempty"`
|
||||||
|
HeaderVideo string `json:"header_video,omitempty"`
|
||||||
ReleaseDate string `json:"release_date,omitempty"`
|
ReleaseDate string `json:"release_date,omitempty"`
|
||||||
TotalTracks int `json:"total_tracks"`
|
TotalTracks int `json:"total_tracks"`
|
||||||
AlbumType string `json:"album_type,omitempty"`
|
AlbumType string `json:"album_type,omitempty"`
|
||||||
|
AudioTraits []string `json:"audio_traits,omitempty"`
|
||||||
Tracks []ExtTrackMetadata `json:"tracks"`
|
Tracks []ExtTrackMetadata `json:"tracks"`
|
||||||
ProviderID string `json:"provider_id"`
|
ProviderID string `json:"provider_id"`
|
||||||
}
|
}
|
||||||
@@ -75,6 +84,7 @@ type ExtArtistMetadata struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ImageURL string `json:"image_url,omitempty"`
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
HeaderImage string `json:"header_image,omitempty"`
|
HeaderImage string `json:"header_image,omitempty"`
|
||||||
|
HeaderVideo string `json:"header_video,omitempty"`
|
||||||
Listeners int `json:"listeners,omitempty"`
|
Listeners int `json:"listeners,omitempty"`
|
||||||
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
||||||
Releases []ExtAlbumMetadata `json:"releases,omitempty"`
|
Releases []ExtAlbumMetadata `json:"releases,omitempty"`
|
||||||
@@ -377,6 +387,64 @@ func shouldStopProviderFallback(availability *ExtAvailabilityResult) bool {
|
|||||||
return availability != nil && availability.SkipFallback
|
return availability != nil && availability.SkipFallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func fallbackRuntimeHealthStatus(ext *loadedExtension) string {
|
||||||
|
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
status := strings.ToLower(strings.TrimSpace(CheckExtensionHealthCached(ext).Status))
|
||||||
|
switch status {
|
||||||
|
case "online", "degraded", "offline":
|
||||||
|
return status
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prioritizeFallbackProvidersByHealth(priority []string, extManager *extensionManager, sourceProvider string) []string {
|
||||||
|
if len(priority) == 0 || extManager == nil {
|
||||||
|
return priority
|
||||||
|
}
|
||||||
|
|
||||||
|
online := make([]string, 0, len(priority))
|
||||||
|
degraded := make([]string, 0, len(priority))
|
||||||
|
unknown := make([]string, 0, len(priority))
|
||||||
|
|
||||||
|
for _, rawProviderID := range priority {
|
||||||
|
providerID := strings.TrimSpace(rawProviderID)
|
||||||
|
if providerID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(providerID, sourceProvider) || !isExtensionFallbackAllowed(providerID) {
|
||||||
|
unknown = append(unknown, providerID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ext, err := extManager.GetExtension(providerID)
|
||||||
|
if err != nil || ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil || !ext.Manifest.IsDownloadProvider() {
|
||||||
|
unknown = append(unknown, providerID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch fallbackRuntimeHealthStatus(ext) {
|
||||||
|
case "online":
|
||||||
|
online = append(online, providerID)
|
||||||
|
case "degraded":
|
||||||
|
degraded = append(degraded, providerID)
|
||||||
|
case "offline":
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (service health offline)\n", providerID)
|
||||||
|
default:
|
||||||
|
unknown = append(unknown, providerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, 0, len(online)+len(degraded)+len(unknown))
|
||||||
|
result = append(result, online...)
|
||||||
|
result = append(result, degraded...)
|
||||||
|
result = append(result, unknown...)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func resolveExtensionAvailabilityReason(availability *ExtAvailabilityResult, err error) string {
|
func resolveExtensionAvailabilityReason(availability *ExtAvailabilityResult, err error) string {
|
||||||
if availability != nil {
|
if availability != nil {
|
||||||
if reason := strings.TrimSpace(availability.Reason); reason != "" {
|
if reason := strings.TrimSpace(availability.Reason); reason != "" {
|
||||||
@@ -391,10 +459,14 @@ func resolveExtensionAvailabilityReason(availability *ExtAvailabilityResult, err
|
|||||||
|
|
||||||
func buildExtensionFallbackStoppedResponse(providerID string, availability *ExtAvailabilityResult, err error) *DownloadResponse {
|
func buildExtensionFallbackStoppedResponse(providerID string, availability *ExtAvailabilityResult, err error) *DownloadResponse {
|
||||||
reason := resolveExtensionAvailabilityReason(availability, err)
|
reason := resolveExtensionAvailabilityReason(availability, err)
|
||||||
|
errorType := classifyDownloadErrorType(reason)
|
||||||
|
if errorType == "unknown" {
|
||||||
|
errorType = "extension_error"
|
||||||
|
}
|
||||||
return &DownloadResponse{
|
return &DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: fmt.Sprintf("Fallback stopped by %s: %s", providerID, reason),
|
Error: fmt.Sprintf("Fallback stopped by %s: %s", providerID, reason),
|
||||||
ErrorType: "extension_error",
|
ErrorType: errorType,
|
||||||
Service: providerID,
|
Service: providerID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -406,6 +478,18 @@ func shouldAbortCancelledFallback(itemID string, err error) bool {
|
|||||||
return itemID != "" && isDownloadCancelled(itemID)
|
return itemID != "" && isDownloadCancelled(itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeExtensionDownloadErrorType(errorType, message string) string {
|
||||||
|
normalized := strings.TrimSpace(errorType)
|
||||||
|
classified := classifyDownloadErrorType(message)
|
||||||
|
if classified != "" && classified != "unknown" {
|
||||||
|
switch strings.ToLower(normalized) {
|
||||||
|
case "", "unknown", "runtime_error", "api_error", "download_error", "extension_error":
|
||||||
|
return classified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
type DownloadDecryptionInfo struct {
|
type DownloadDecryptionInfo struct {
|
||||||
Strategy string `json:"strategy,omitempty"`
|
Strategy string `json:"strategy,omitempty"`
|
||||||
Key string `json:"key,omitempty"`
|
Key string `json:"key,omitempty"`
|
||||||
@@ -416,14 +500,15 @@ type DownloadDecryptionInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ExtDownloadResult struct {
|
type ExtDownloadResult struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
FilePath string `json:"file_path,omitempty"`
|
FilePath string `json:"file_path,omitempty"`
|
||||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||||
BitDepth int `json:"bit_depth,omitempty"`
|
BitDepth int `json:"bit_depth,omitempty"`
|
||||||
SampleRate int `json:"sample_rate,omitempty"`
|
SampleRate int `json:"sample_rate,omitempty"`
|
||||||
AudioCodec string `json:"audio_codec,omitempty"`
|
AudioCodec string `json:"audio_codec,omitempty"`
|
||||||
ErrorMessage string `json:"error_message,omitempty"`
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
ErrorType string `json:"error_type,omitempty"`
|
ErrorType string `json:"error_type,omitempty"`
|
||||||
|
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
|
||||||
|
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Artist string `json:"artist,omitempty"`
|
Artist string `json:"artist,omitempty"`
|
||||||
@@ -657,6 +742,32 @@ func gojaObjectStringMap(vm *goja.Runtime, obj *goja.Object, keys ...string) map
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func gojaObjectStringSlice(obj *goja.Object, keys ...string) []string {
|
||||||
|
value := gojaObjectValue(obj, keys...)
|
||||||
|
if gojaValueIsEmpty(value) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
exported, ok := value.Export().([]interface{})
|
||||||
|
if !ok || len(exported) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]string, 0, len(exported))
|
||||||
|
for _, item := range exported {
|
||||||
|
str, ok := item.(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
str = strings.TrimSpace(str)
|
||||||
|
if str != "" {
|
||||||
|
result = append(result, str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(result) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func gojaArrayLength(value goja.Value, vm *goja.Runtime) (int, error) {
|
func gojaArrayLength(value goja.Value, vm *goja.Runtime) (int, error) {
|
||||||
if gojaValueIsEmpty(value) {
|
if gojaValueIsEmpty(value) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
@@ -680,8 +791,14 @@ func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetada
|
|||||||
Artists: gojaObjectString(obj, "artists"),
|
Artists: gojaObjectString(obj, "artists"),
|
||||||
AlbumName: gojaObjectString(obj, "album_name", "albumName"),
|
AlbumName: gojaObjectString(obj, "album_name", "albumName"),
|
||||||
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
|
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
|
||||||
|
AlbumID: gojaObjectString(obj, "album_id", "albumId"),
|
||||||
|
AlbumURL: gojaObjectString(obj, "album_url", "albumUrl"),
|
||||||
|
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
|
||||||
|
ArtistURL: gojaObjectString(obj, "artist_url", "artistUrl"),
|
||||||
|
ExternalURL: gojaObjectString(obj, "external_urls", "externalUrls", "external_url", "externalUrl", "url"),
|
||||||
DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"),
|
DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"),
|
||||||
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
||||||
|
PreviewURL: gojaObjectString(obj, "preview_url", "previewUrl"),
|
||||||
Images: gojaObjectString(obj, "images"),
|
Images: gojaObjectString(obj, "images"),
|
||||||
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
|
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
|
||||||
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
|
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
|
||||||
@@ -748,12 +865,147 @@ func parseExtensionAlbumValue(vm *goja.Runtime, value goja.Value) (ExtAlbumMetad
|
|||||||
Artists: gojaObjectString(obj, "artists"),
|
Artists: gojaObjectString(obj, "artists"),
|
||||||
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
|
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
|
||||||
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl", "images"),
|
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl", "images"),
|
||||||
|
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
|
||||||
|
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
|
||||||
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
|
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
|
||||||
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
|
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
|
||||||
AlbumType: gojaObjectString(obj, "album_type", "albumType"),
|
AlbumType: gojaObjectString(obj, "album_type", "albumType"),
|
||||||
|
AudioTraits: gojaObjectStringSlice(obj, "audio_traits", "audioTraits"),
|
||||||
Tracks: tracks,
|
Tracks: tracks,
|
||||||
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
|
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
|
||||||
}, nil
|
}.withTrackFallbacks(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// withTrackFallbacks fills the album-level artist and release date from the
|
||||||
|
// album's tracks when the extension did not provide them at the album level.
|
||||||
|
// This is a generic mechanism so any extension benefits, without per-extension
|
||||||
|
// special-casing in the app.
|
||||||
|
func (a ExtAlbumMetadata) withTrackFallbacks() ExtAlbumMetadata {
|
||||||
|
if strings.TrimSpace(a.Artists) == "" {
|
||||||
|
a.Artists = albumArtistFromTracks(a.Tracks)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(a.ReleaseDate) == "" {
|
||||||
|
a.ReleaseDate = albumReleaseDateFromTracks(a.Tracks)
|
||||||
|
}
|
||||||
|
if len(a.AudioTraits) == 0 {
|
||||||
|
a.AudioTraits = albumAudioTraitsFromTracks(a.Tracks)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// albumArtistFromTracks prefers an explicit per-track album artist, then falls
|
||||||
|
// back to the most common track artist across the album.
|
||||||
|
func albumArtistFromTracks(tracks []ExtTrackMetadata) string {
|
||||||
|
for _, t := range tracks {
|
||||||
|
if s := strings.TrimSpace(t.AlbumArtist); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
counts := map[string]int{}
|
||||||
|
order := []string{}
|
||||||
|
for _, t := range tracks {
|
||||||
|
artist := strings.TrimSpace(t.Artists)
|
||||||
|
if artist == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := counts[artist]; !ok {
|
||||||
|
order = append(order, artist)
|
||||||
|
}
|
||||||
|
counts[artist]++
|
||||||
|
}
|
||||||
|
best := ""
|
||||||
|
bestCount := 0
|
||||||
|
for _, artist := range order {
|
||||||
|
if counts[artist] > bestCount {
|
||||||
|
best = artist
|
||||||
|
bestCount = counts[artist]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// albumReleaseDateFromTracks returns the first non-empty track release date.
|
||||||
|
func albumReleaseDateFromTracks(tracks []ExtTrackMetadata) string {
|
||||||
|
for _, t := range tracks {
|
||||||
|
if s := strings.TrimSpace(t.ReleaseDate); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// albumAudioTraitsFromTracks derives album-level audio badges (Dolby Atmos,
|
||||||
|
// Hi-Res Lossless, Lossless) from the per-track audio quality/mode fields that
|
||||||
|
// extensions like Tidal and Qobuz already provide. Tokens match what the album
|
||||||
|
// header understands ("dolby_atmos", "hi_res_lossless", "lossless").
|
||||||
|
func albumAudioTraitsFromTracks(tracks []ExtTrackMetadata) []string {
|
||||||
|
atmos := false
|
||||||
|
hiRes := false
|
||||||
|
lossless := false
|
||||||
|
|
||||||
|
for _, t := range tracks {
|
||||||
|
modes := strings.ToUpper(t.AudioModes)
|
||||||
|
quality := strings.ToUpper(t.AudioQuality)
|
||||||
|
if strings.Contains(modes, "ATMOS") || strings.Contains(quality, "ATMOS") {
|
||||||
|
atmos = true
|
||||||
|
}
|
||||||
|
if strings.Contains(quality, "HI_RES") ||
|
||||||
|
strings.Contains(quality, "HIRES") ||
|
||||||
|
strings.Contains(quality, "MASTER") ||
|
||||||
|
strings.Contains(quality, "MQA") {
|
||||||
|
hiRes = true
|
||||||
|
}
|
||||||
|
if strings.Contains(quality, "LOSSLESS") ||
|
||||||
|
strings.Contains(quality, "FLAC") {
|
||||||
|
lossless = true
|
||||||
|
}
|
||||||
|
if bd, sr := parseBitDepthSampleRate(quality); bd > 0 {
|
||||||
|
if bd > 16 || sr > 48 {
|
||||||
|
hiRes = true
|
||||||
|
} else {
|
||||||
|
lossless = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
traits := []string{}
|
||||||
|
if atmos {
|
||||||
|
traits = append(traits, "dolby_atmos")
|
||||||
|
}
|
||||||
|
if hiRes {
|
||||||
|
traits = append(traits, "hi_res_lossless")
|
||||||
|
} else if lossless {
|
||||||
|
traits = append(traits, "lossless")
|
||||||
|
}
|
||||||
|
return traits
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBitDepthSampleRate extracts a bit depth and sample rate (in kHz) from
|
||||||
|
// labels such as "24bit/96kHz", "16bit/44.1kHz" or "24bit".
|
||||||
|
func parseBitDepthSampleRate(quality string) (int, float64) {
|
||||||
|
lower := strings.ToLower(quality)
|
||||||
|
bitDepth := 0
|
||||||
|
sampleRate := 0.0
|
||||||
|
|
||||||
|
if idx := strings.Index(lower, "bit"); idx > 0 {
|
||||||
|
j := idx
|
||||||
|
for j > 0 && lower[j-1] >= '0' && lower[j-1] <= '9' {
|
||||||
|
j--
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(lower[j:idx]); err == nil {
|
||||||
|
bitDepth = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx := strings.Index(lower, "khz"); idx > 0 {
|
||||||
|
j := idx
|
||||||
|
for j > 0 && ((lower[j-1] >= '0' && lower[j-1] <= '9') || lower[j-1] == '.') {
|
||||||
|
j--
|
||||||
|
}
|
||||||
|
if f, err := strconv.ParseFloat(lower[j:idx], 64); err == nil {
|
||||||
|
sampleRate = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bitDepth, sampleRate
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseExtensionAlbumArray(vm *goja.Runtime, value goja.Value) ([]ExtAlbumMetadata, error) {
|
func parseExtensionAlbumArray(vm *goja.Runtime, value goja.Value) ([]ExtAlbumMetadata, error) {
|
||||||
@@ -819,6 +1071,7 @@ func parseExtensionArtistValue(vm *goja.Runtime, value goja.Value) (ExtArtistMet
|
|||||||
Name: gojaObjectString(obj, "name"),
|
Name: gojaObjectString(obj, "name"),
|
||||||
ImageURL: gojaObjectString(obj, "image_url", "imageUrl"),
|
ImageURL: gojaObjectString(obj, "image_url", "imageUrl"),
|
||||||
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
|
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
|
||||||
|
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
|
||||||
Listeners: gojaObjectInt(obj, "listeners"),
|
Listeners: gojaObjectInt(obj, "listeners"),
|
||||||
Albums: albums,
|
Albums: albums,
|
||||||
Releases: releases,
|
Releases: releases,
|
||||||
@@ -870,35 +1123,36 @@ func parseExtensionDownloadDecryptionValue(vm *goja.Runtime, value goja.Value) *
|
|||||||
func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDownloadResult {
|
func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDownloadResult {
|
||||||
obj := value.ToObject(vm)
|
obj := value.ToObject(vm)
|
||||||
return ExtDownloadResult{
|
return ExtDownloadResult{
|
||||||
Success: gojaObjectBool(obj, "success"),
|
Success: gojaObjectBool(obj, "success"),
|
||||||
FilePath: gojaObjectString(obj, "file_path", "filePath", "path"),
|
FilePath: gojaObjectString(obj, "file_path", "filePath", "path"),
|
||||||
AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"),
|
AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"),
|
||||||
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
|
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
|
||||||
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
|
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
|
||||||
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
|
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
|
||||||
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
|
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
|
||||||
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
|
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
|
||||||
Title: gojaObjectString(obj, "title"),
|
RetryAfterSeconds: gojaObjectInt(obj, "retry_after_seconds", "retryAfterSeconds"),
|
||||||
Artist: gojaObjectString(obj, "artist"),
|
Title: gojaObjectString(obj, "title"),
|
||||||
Album: gojaObjectString(obj, "album"),
|
Artist: gojaObjectString(obj, "artist"),
|
||||||
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
|
Album: gojaObjectString(obj, "album"),
|
||||||
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
|
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
|
||||||
DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"),
|
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
|
||||||
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
|
DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"),
|
||||||
TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"),
|
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
|
||||||
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
|
TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"),
|
||||||
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
|
||||||
ISRC: gojaObjectString(obj, "isrc"),
|
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
||||||
Genre: gojaObjectString(obj, "genre"),
|
ISRC: gojaObjectString(obj, "isrc"),
|
||||||
Label: gojaObjectString(obj, "label"),
|
Genre: gojaObjectString(obj, "genre"),
|
||||||
Copyright: gojaObjectString(obj, "copyright"),
|
Label: gojaObjectString(obj, "label"),
|
||||||
Composer: gojaObjectString(obj, "composer"),
|
Copyright: gojaObjectString(obj, "copyright"),
|
||||||
LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"),
|
Composer: gojaObjectString(obj, "composer"),
|
||||||
DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"),
|
LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"),
|
||||||
Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")),
|
DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"),
|
||||||
ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"),
|
Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")),
|
||||||
OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"),
|
ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"),
|
||||||
ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"),
|
OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"),
|
||||||
|
ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"),
|
||||||
RequiresContainerConversion: gojaObjectBool(
|
RequiresContainerConversion: gojaObjectBool(
|
||||||
obj,
|
obj,
|
||||||
"requires_container_conversion",
|
"requires_container_conversion",
|
||||||
@@ -910,9 +1164,11 @@ func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDo
|
|||||||
func parseExtensionURLHandleValue(vm *goja.Runtime, value goja.Value) (ExtURLHandleResult, error) {
|
func parseExtensionURLHandleValue(vm *goja.Runtime, value goja.Value) (ExtURLHandleResult, error) {
|
||||||
obj := value.ToObject(vm)
|
obj := value.ToObject(vm)
|
||||||
handleResult := ExtURLHandleResult{
|
handleResult := ExtURLHandleResult{
|
||||||
Type: gojaObjectString(obj, "type"),
|
Type: gojaObjectString(obj, "type"),
|
||||||
Name: gojaObjectString(obj, "name"),
|
Name: gojaObjectString(obj, "name"),
|
||||||
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
||||||
|
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
|
||||||
|
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if trackValue := gojaObjectValue(obj, "track"); !gojaValueIsEmpty(trackValue) {
|
if trackValue := gojaObjectValue(obj, "track"); !gojaValueIsEmpty(trackValue) {
|
||||||
@@ -1786,7 +2042,9 @@ func isRetiredBuiltInDownloadProvider(providerID string) bool {
|
|||||||
}
|
}
|
||||||
switch normalized {
|
switch normalized {
|
||||||
case "deezer", "qobuz", "tidal":
|
case "deezer", "qobuz", "tidal":
|
||||||
return true
|
return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool {
|
||||||
|
return manifest.IsDownloadProvider()
|
||||||
|
})
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1799,12 +2057,36 @@ func isRetiredBuiltInMetadataProvider(providerID string) bool {
|
|||||||
}
|
}
|
||||||
switch normalized {
|
switch normalized {
|
||||||
case "deezer", "spotify", "qobuz", "tidal":
|
case "deezer", "spotify", "qobuz", "tidal":
|
||||||
return true
|
return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool {
|
||||||
|
return manifest.IsMetadataProvider()
|
||||||
|
})
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasEnabledExtensionProvider(providerID string, matches func(*ExtensionManifest) bool) bool {
|
||||||
|
if providerID == "" || matches == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := getExtensionManager()
|
||||||
|
manager.mu.RLock()
|
||||||
|
defer manager.mu.RUnlock()
|
||||||
|
|
||||||
|
for id, ext := range manager.extensions {
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(id), providerID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return matches(ext.Manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func SetExtensionFallbackProviderIDs(providerIDs []string) {
|
func SetExtensionFallbackProviderIDs(providerIDs []string) {
|
||||||
extensionFallbackProviderIDsMu.Lock()
|
extensionFallbackProviderIDsMu.Lock()
|
||||||
defer extensionFallbackProviderIDsMu.Unlock()
|
defer extensionFallbackProviderIDsMu.Unlock()
|
||||||
@@ -2037,6 +2319,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
var lastErrType string
|
||||||
|
var lastRetryAfterSeconds int
|
||||||
var stopProviderFallback bool
|
var stopProviderFallback bool
|
||||||
var sourceExtensionLocked bool
|
var sourceExtensionLocked bool
|
||||||
var sourceExtensionAvailability *ExtAvailabilityResult
|
var sourceExtensionAvailability *ExtAvailabilityResult
|
||||||
@@ -2318,15 +2602,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
resp.Composer = req.Composer
|
resp.Composer = req.Composer
|
||||||
}
|
}
|
||||||
|
|
||||||
if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) {
|
embedExtensionDownloadMetadata(resp, req, alreadyExists)
|
||||||
if err := EmbedGenreLabel(normalizedResult.FilePath, req.Genre, req.Label); err != nil {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
|
||||||
} else {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
|
|
||||||
}
|
|
||||||
} else if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", normalizedResult.FilePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
||||||
indexISRC := strings.TrimSpace(resp.ISRC)
|
indexISRC := strings.TrimSpace(resp.ISRC)
|
||||||
@@ -2351,11 +2627,24 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
lastErrType = ""
|
||||||
} else if result.ErrorMessage != "" {
|
} else if result.ErrorMessage != "" {
|
||||||
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
||||||
|
lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage)
|
||||||
|
lastRetryAfterSeconds = result.RetryAfterSeconds
|
||||||
}
|
}
|
||||||
GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr)
|
GoLog("[DownloadWithExtensionFallback] Source extension %s failed: %v\n", req.Source, lastErr)
|
||||||
|
|
||||||
|
if strings.EqualFold(lastErrType, "verification_required") {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Source extension %s requires verification, not trying other providers\n", req.Source)
|
||||||
|
return &DownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Download failed: " + lastErr.Error(),
|
||||||
|
ErrorType: "verification_required",
|
||||||
|
Service: req.Source,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
if stopProviderFallback || sourceExtensionLocked {
|
if stopProviderFallback || sourceExtensionLocked {
|
||||||
if sourceExtensionLocked {
|
if sourceExtensionLocked {
|
||||||
GoLog("[DownloadWithExtensionFallback] Source extension %s requested skip_fallback, not trying other providers\n", req.Source)
|
GoLog("[DownloadWithExtensionFallback] Source extension %s requested skip_fallback, not trying other providers\n", req.Source)
|
||||||
@@ -2363,10 +2652,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
GoLog("[DownloadWithExtensionFallback] stopProviderFallback is true, not trying other providers\n")
|
GoLog("[DownloadWithExtensionFallback] stopProviderFallback is true, not trying other providers\n")
|
||||||
return &DownloadResponse{
|
return &DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: "Download failed: " + lastErr.Error(),
|
Error: "Download failed: " + lastErr.Error(),
|
||||||
ErrorType: "extension_error",
|
ErrorType: firstNonEmptyString(lastErrType, "extension_error"),
|
||||||
Service: req.Source,
|
RetryAfterSeconds: lastRetryAfterSeconds,
|
||||||
|
Service: req.Source,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -2374,6 +2664,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
priority = prioritizeFallbackProvidersByHealth(priority, extManager, req.Source)
|
||||||
|
|
||||||
for _, providerID := range priority {
|
for _, providerID := range priority {
|
||||||
if isDownloadCancelled(req.ItemID) {
|
if isDownloadCancelled(req.ItemID) {
|
||||||
return nil, ErrDownloadCancelled
|
return nil, ErrDownloadCancelled
|
||||||
@@ -2383,11 +2675,13 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
if providerID == "" {
|
if providerID == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if providerID == req.Source {
|
// Skip the origin extension only when it differs from the explicitly
|
||||||
|
// selected provider; otherwise it must still be attempted here.
|
||||||
|
if providerID == req.Source && req.Source != selectedProvider {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isExtensionFallbackAllowed(providerID) {
|
if providerID != selectedProvider && !isExtensionFallbackAllowed(providerID) {
|
||||||
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (not enabled for fallback)\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (not enabled for fallback)\n", providerID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -2416,6 +2710,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
if strings.EqualFold(classifyDownloadErrorType(err.Error()), "verification_required") {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] %s requires verification (availability); pausing fallback to open the challenge\n", providerID)
|
||||||
|
return &DownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Download failed: " + err.Error(),
|
||||||
|
ErrorType: "verification_required",
|
||||||
|
Service: providerID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if terminalAvailability {
|
if terminalAvailability {
|
||||||
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after availability check\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after availability check\n", providerID)
|
||||||
@@ -2430,7 +2733,30 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
StartItemProgress(req.ItemID)
|
StartItemProgress(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, req.ItemID, func(percent int) {
|
// Honor the requested quality when this provider recognizes it
|
||||||
|
// (e.g. an explicit user selection). Only when the token is not
|
||||||
|
// one of this provider's own options do we fall back to its
|
||||||
|
// highest quality, since a source provider's token may not map.
|
||||||
|
fallbackQuality := req.Quality
|
||||||
|
if len(ext.Manifest.QualityOptions) > 0 {
|
||||||
|
requested := strings.TrimSpace(req.Quality)
|
||||||
|
recognized := false
|
||||||
|
if requested != "" {
|
||||||
|
for _, opt := range ext.Manifest.QualityOptions {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(opt.ID), requested) {
|
||||||
|
recognized = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !recognized {
|
||||||
|
if best := strings.TrimSpace(ext.Manifest.QualityOptions[0].ID); best != "" {
|
||||||
|
fallbackQuality = best
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := provider.Download(availability.TrackID, fallbackQuality, outputPath, req.ItemID, func(percent int) {
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
normalized := float64(percent) / 100.0
|
normalized := float64(percent) / 100.0
|
||||||
if normalized < 0 {
|
if normalized < 0 {
|
||||||
@@ -2474,15 +2800,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
applyExtensionRequestFallbacks(&resp, req)
|
applyExtensionRequestFallbacks(&resp, req)
|
||||||
|
|
||||||
if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) {
|
embedExtensionDownloadMetadata(resp, req, alreadyExists)
|
||||||
if err := EmbedGenreLabel(normalizedResult.FilePath, req.Genre, req.Label); err != nil {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
|
||||||
} else {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
|
|
||||||
}
|
|
||||||
} else if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") {
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Skipping genre/label embed for non-local output path: %q\n", normalizedResult.FilePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
||||||
indexISRC := strings.TrimSpace(resp.ISRC)
|
indexISRC := strings.TrimSpace(resp.ISRC)
|
||||||
@@ -2507,10 +2825,31 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
lastErrType = ""
|
||||||
} else if result.ErrorMessage != "" {
|
} else if result.ErrorMessage != "" {
|
||||||
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
||||||
|
lastErrType = normalizeExtensionDownloadErrorType(result.ErrorType, result.ErrorMessage)
|
||||||
|
lastRetryAfterSeconds = result.RetryAfterSeconds
|
||||||
}
|
}
|
||||||
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, lastErr)
|
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, lastErr)
|
||||||
|
|
||||||
|
if lastErr != nil {
|
||||||
|
effType := lastErrType
|
||||||
|
if effType == "" {
|
||||||
|
effType = classifyDownloadErrorType(lastErr.Error())
|
||||||
|
}
|
||||||
|
if strings.EqualFold(effType, "verification_required") {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] %s requires verification; pausing fallback to open the challenge\n", providerID)
|
||||||
|
return &DownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Download failed: " + lastErr.Error(),
|
||||||
|
ErrorType: "verification_required",
|
||||||
|
RetryAfterSeconds: lastRetryAfterSeconds,
|
||||||
|
Service: providerID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if terminalAvailability {
|
if terminalAvailability {
|
||||||
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after download failure\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after download failure\n", providerID)
|
||||||
return buildExtensionFallbackStoppedResponse(providerID, availability, lastErr), nil
|
return buildExtensionFallbackStoppedResponse(providerID, availability, lastErr), nil
|
||||||
@@ -2519,10 +2858,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
if lastErr != nil {
|
if lastErr != nil {
|
||||||
|
errorType := firstNonEmptyString(lastErrType, classifyDownloadErrorType(lastErr.Error()))
|
||||||
|
if errorType == "unknown" {
|
||||||
|
errorType = "not_found"
|
||||||
|
}
|
||||||
return &DownloadResponse{
|
return &DownloadResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: "All providers failed. Last error: " + lastErr.Error(),
|
Error: "All providers failed. Last error: " + lastErr.Error(),
|
||||||
ErrorType: "not_found",
|
ErrorType: errorType,
|
||||||
|
RetryAfterSeconds: lastRetryAfterSeconds,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2539,27 +2883,29 @@ func buildOutputPath(req DownloadRequest) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
metadata := map[string]interface{}{
|
metadata := map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
"album": req.AlbumName,
|
"album": req.AlbumName,
|
||||||
"album_artist": req.AlbumArtist,
|
"album_artist": req.AlbumArtist,
|
||||||
"track": req.TrackNumber,
|
"track": req.TrackNumber,
|
||||||
"track_number": req.TrackNumber,
|
"track_number": req.TrackNumber,
|
||||||
"total_tracks": req.TotalTracks,
|
"total_tracks": req.TotalTracks,
|
||||||
"disc": req.DiscNumber,
|
"playlist_position": req.PlaylistPosition,
|
||||||
"disc_number": req.DiscNumber,
|
"disc": req.DiscNumber,
|
||||||
"total_discs": req.TotalDiscs,
|
"disc_number": req.DiscNumber,
|
||||||
"year": extractYear(req.ReleaseDate),
|
"total_discs": req.TotalDiscs,
|
||||||
"date": req.ReleaseDate,
|
"year": extractYear(req.ReleaseDate),
|
||||||
"release_date": req.ReleaseDate,
|
"date": req.ReleaseDate,
|
||||||
"isrc": req.ISRC,
|
"release_date": req.ReleaseDate,
|
||||||
"composer": req.Composer,
|
"isrc": req.ISRC,
|
||||||
|
"composer": req.Composer,
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||||
if filename == "" {
|
if strings.TrimSpace(filename) == "" {
|
||||||
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
|
filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)
|
||||||
}
|
}
|
||||||
|
filename = sanitizeFilename(filename)
|
||||||
|
|
||||||
ext := strings.TrimSpace(req.OutputExt)
|
ext := strings.TrimSpace(req.OutputExt)
|
||||||
if ext == "" {
|
if ext == "" {
|
||||||
@@ -2597,27 +2943,29 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri
|
|||||||
AddAllowedDownloadDir(tempDir)
|
AddAllowedDownloadDir(tempDir)
|
||||||
|
|
||||||
metadata := map[string]interface{}{
|
metadata := map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
"album": req.AlbumName,
|
"album": req.AlbumName,
|
||||||
"album_artist": req.AlbumArtist,
|
"album_artist": req.AlbumArtist,
|
||||||
"track": req.TrackNumber,
|
"track": req.TrackNumber,
|
||||||
"track_number": req.TrackNumber,
|
"track_number": req.TrackNumber,
|
||||||
"total_tracks": req.TotalTracks,
|
"total_tracks": req.TotalTracks,
|
||||||
"disc": req.DiscNumber,
|
"playlist_position": req.PlaylistPosition,
|
||||||
"disc_number": req.DiscNumber,
|
"disc": req.DiscNumber,
|
||||||
"total_discs": req.TotalDiscs,
|
"disc_number": req.DiscNumber,
|
||||||
"year": extractYear(req.ReleaseDate),
|
"total_discs": req.TotalDiscs,
|
||||||
"date": req.ReleaseDate,
|
"year": extractYear(req.ReleaseDate),
|
||||||
"release_date": req.ReleaseDate,
|
"date": req.ReleaseDate,
|
||||||
"isrc": req.ISRC,
|
"release_date": req.ReleaseDate,
|
||||||
"composer": req.Composer,
|
"isrc": req.ISRC,
|
||||||
|
"composer": req.Composer,
|
||||||
}
|
}
|
||||||
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||||
if filename == "" {
|
if strings.TrimSpace(filename) == "" {
|
||||||
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
|
filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)
|
||||||
}
|
}
|
||||||
|
filename = sanitizeFilename(filename)
|
||||||
|
|
||||||
outputExt := strings.TrimSpace(req.OutputExt)
|
outputExt := strings.TrimSpace(req.OutputExt)
|
||||||
if outputExt == "" {
|
if outputExt == "" {
|
||||||
@@ -2644,6 +2992,78 @@ func canEmbedGenreLabel(filePath string) bool {
|
|||||||
return err == nil && !info.IsDir() && info.Size() > 0
|
return err == nil && !info.IsDir() && info.Size() > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func embedExtensionDownloadMetadata(resp DownloadResponse, req DownloadRequest, alreadyExists bool) {
|
||||||
|
if alreadyExists || !req.EmbedMetadata {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := strings.TrimSpace(resp.FilePath)
|
||||||
|
if !canEmbedGenreLabel(filePath) {
|
||||||
|
if req.Genre != "" || req.Label != "" || resp.CoverURL != "" || req.CoverURL != "" {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Skipping metadata/cover embed for non-local FLAC output path: %q\n", filePath)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
coverURL := firstNonEmptyTrimmed(resp.CoverURL, req.CoverURL)
|
||||||
|
var coverData []byte
|
||||||
|
if coverURL != "" {
|
||||||
|
data, err := downloadCoverToMemory(coverURL, req.EmbedMaxQualityCover)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Warning: failed to download cover for metadata embed: %v\n", err)
|
||||||
|
} else if len(data) > 0 {
|
||||||
|
coverData = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := Metadata{
|
||||||
|
Title: firstNonEmptyTrimmed(resp.Title, req.TrackName),
|
||||||
|
Artist: firstNonEmptyTrimmed(resp.Artist, req.ArtistName),
|
||||||
|
Album: firstNonEmptyTrimmed(resp.Album, req.AlbumName),
|
||||||
|
AlbumArtist: firstNonEmptyTrimmed(resp.AlbumArtist, req.AlbumArtist),
|
||||||
|
ArtistTagMode: req.ArtistTagMode,
|
||||||
|
Date: firstNonEmptyTrimmed(resp.ReleaseDate, req.ReleaseDate),
|
||||||
|
TrackNumber: firstPositiveInt(resp.TrackNumber, req.TrackNumber),
|
||||||
|
TotalTracks: firstPositiveInt(resp.TotalTracks, req.TotalTracks),
|
||||||
|
DiscNumber: firstPositiveInt(resp.DiscNumber, req.DiscNumber),
|
||||||
|
TotalDiscs: firstPositiveInt(resp.TotalDiscs, req.TotalDiscs),
|
||||||
|
ISRC: firstNonEmptyTrimmed(resp.ISRC, req.ISRC),
|
||||||
|
Genre: firstNonEmptyTrimmed(resp.Genre, req.Genre),
|
||||||
|
Label: firstNonEmptyTrimmed(resp.Label, req.Label),
|
||||||
|
Copyright: firstNonEmptyTrimmed(resp.Copyright, req.Copyright),
|
||||||
|
Composer: firstNonEmptyTrimmed(resp.Composer, req.Composer),
|
||||||
|
}
|
||||||
|
if req.EmbedLyrics {
|
||||||
|
metadata.Lyrics = resp.LyricsLRC
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if len(coverData) > 0 {
|
||||||
|
err = EmbedMetadataWithCoverData(filePath, metadata, coverData)
|
||||||
|
} else {
|
||||||
|
err = EmbedMetadata(filePath, metadata, "")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed metadata/cover: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(coverData) > 0 {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Embedded metadata and cover from %q\n", coverURL)
|
||||||
|
} else {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Embedded metadata without cover\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstPositiveInt(values ...int) int {
|
||||||
|
for _, value := range values {
|
||||||
|
if value > 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
|
func (p *extensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
|
||||||
return p.customSearch(query, options, "", "")
|
return p.customSearch(query, options, "", "")
|
||||||
}
|
}
|
||||||
@@ -2767,13 +3187,15 @@ func (p *extensionProviderWrapper) customSearch(query string, options map[string
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ExtURLHandleResult struct {
|
type ExtURLHandleResult struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Track *ExtTrackMetadata `json:"track,omitempty"`
|
Track *ExtTrackMetadata `json:"track,omitempty"`
|
||||||
Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
|
Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
|
||||||
Album *ExtAlbumMetadata `json:"album,omitempty"`
|
Album *ExtAlbumMetadata `json:"album,omitempty"`
|
||||||
Artist *ExtArtistMetadata `json:"artist,omitempty"`
|
Artist *ExtArtistMetadata `json:"artist,omitempty"`
|
||||||
Name string `json:"name,omitempty"`
|
Name string `json:"name,omitempty"`
|
||||||
CoverURL string `json:"cover_url,omitempty"`
|
CoverURL string `json:"cover_url,omitempty"`
|
||||||
|
HeaderImage string `json:"header_image,omitempty"`
|
||||||
|
HeaderVideo string `json:"header_video,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
|
func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -92,6 +93,125 @@ func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSetProviderPriorityKeepsExtensionNamedLikeRetiredDownloader(t *testing.T) {
|
||||||
|
original := GetProviderPriority()
|
||||||
|
defer SetProviderPriority(original)
|
||||||
|
|
||||||
|
manager := getExtensionManager()
|
||||||
|
ext := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||||
|
ext.ID = "deezer"
|
||||||
|
ext.Manifest.Name = "deezer"
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
previous, hadPrevious := manager.extensions[ext.ID]
|
||||||
|
manager.extensions[ext.ID] = ext
|
||||||
|
manager.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
manager.mu.Lock()
|
||||||
|
if hadPrevious {
|
||||||
|
manager.extensions[ext.ID] = previous
|
||||||
|
} else {
|
||||||
|
delete(manager.extensions, ext.ID)
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
SetProviderPriority([]string{"deezer", "custom-ext"})
|
||||||
|
|
||||||
|
got := GetProviderPriority()
|
||||||
|
want := []string{"deezer", "custom-ext"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrioritizeFallbackProvidersByHealthPrefersOnlineAndSkipsOffline(t *testing.T) {
|
||||||
|
manager := getExtensionManager()
|
||||||
|
amazon := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||||
|
amazon.ID = "amazon"
|
||||||
|
amazon.Manifest.Name = "amazon"
|
||||||
|
amazon.Manifest.ServiceHealth = []ExtensionHealthCheck{{
|
||||||
|
ID: "main",
|
||||||
|
URL: "://bad",
|
||||||
|
Required: true,
|
||||||
|
}}
|
||||||
|
|
||||||
|
plain := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||||
|
plain.ID = "plain"
|
||||||
|
plain.Manifest.Name = "plain"
|
||||||
|
|
||||||
|
deezer := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||||
|
deezer.ID = "deezer"
|
||||||
|
deezer.Manifest.Name = "deezer"
|
||||||
|
deezer.Manifest.ServiceHealth = []ExtensionHealthCheck{{
|
||||||
|
ID: "main",
|
||||||
|
URL: "https://example.test/health",
|
||||||
|
}}
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
previousAmazon, hadAmazon := manager.extensions[amazon.ID]
|
||||||
|
previousPlain, hadPlain := manager.extensions[plain.ID]
|
||||||
|
previousDeezer, hadDeezer := manager.extensions[deezer.ID]
|
||||||
|
manager.extensions[amazon.ID] = amazon
|
||||||
|
manager.extensions[plain.ID] = plain
|
||||||
|
manager.extensions[deezer.ID] = deezer
|
||||||
|
manager.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
manager.mu.Lock()
|
||||||
|
if hadAmazon {
|
||||||
|
manager.extensions[amazon.ID] = previousAmazon
|
||||||
|
} else {
|
||||||
|
delete(manager.extensions, amazon.ID)
|
||||||
|
}
|
||||||
|
if hadPlain {
|
||||||
|
manager.extensions[plain.ID] = previousPlain
|
||||||
|
} else {
|
||||||
|
delete(manager.extensions, plain.ID)
|
||||||
|
}
|
||||||
|
if hadDeezer {
|
||||||
|
manager.extensions[deezer.ID] = previousDeezer
|
||||||
|
} else {
|
||||||
|
delete(manager.extensions, deezer.ID)
|
||||||
|
}
|
||||||
|
manager.mu.Unlock()
|
||||||
|
|
||||||
|
extensionHealthCacheMu.Lock()
|
||||||
|
delete(extensionHealthCache, deezer.ID)
|
||||||
|
extensionHealthCacheMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
extensionHealthCacheMu.Lock()
|
||||||
|
extensionHealthCache[deezer.ID] = cachedExtensionHealthResult{
|
||||||
|
result: ExtensionHealthResult{
|
||||||
|
ExtensionID: deezer.ID,
|
||||||
|
Status: "online",
|
||||||
|
CheckedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
expiresAt: time.Now().Add(time.Minute),
|
||||||
|
}
|
||||||
|
extensionHealthCacheMu.Unlock()
|
||||||
|
|
||||||
|
got := prioritizeFallbackProvidersByHealth(
|
||||||
|
[]string{"amazon", "plain", "deezer"},
|
||||||
|
manager,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
want := []string{"deezer", "plain"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("unexpected provider order length: got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("unexpected provider order at %d: got %v want %v", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
|
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
|
||||||
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
|
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
|
||||||
if normalized == nil {
|
if normalized == nil {
|
||||||
@@ -286,6 +406,45 @@ func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildOutputPathSanitizesTemplateFilename(t *testing.T) {
|
||||||
|
SetAllowedDownloadDirs(nil)
|
||||||
|
|
||||||
|
outputDir := t.TempDir()
|
||||||
|
outputPath := buildOutputPath(DownloadRequest{
|
||||||
|
TrackName: `Gehra Hua (From "Dhurandhar")`,
|
||||||
|
ArtistName: "Artist",
|
||||||
|
OutputDir: outputDir,
|
||||||
|
OutputExt: ".flac",
|
||||||
|
FilenameFormat: "{artist} - {title}",
|
||||||
|
})
|
||||||
|
|
||||||
|
base := filepath.Base(outputPath)
|
||||||
|
if strings.ContainsAny(base, `<>:"/\|?*`) {
|
||||||
|
t.Fatalf("output filename still contains illegal characters: %q", base)
|
||||||
|
}
|
||||||
|
if strings.Contains(base, `"`) {
|
||||||
|
t.Fatalf("output filename still contains straight double quote: %q", base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildOutputPathForExtensionSanitizesTemplateFilename(t *testing.T) {
|
||||||
|
SetAllowedDownloadDirs(nil)
|
||||||
|
|
||||||
|
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||||
|
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||||
|
TrackName: `Gehra Hua (From "Dhurandhar")`,
|
||||||
|
ArtistName: "Artist",
|
||||||
|
OutputFD: 123,
|
||||||
|
OutputExt: ".flac",
|
||||||
|
FilenameFormat: "{artist} - {title}",
|
||||||
|
}, ext)
|
||||||
|
|
||||||
|
base := filepath.Base(resolved)
|
||||||
|
if strings.ContainsAny(base, `<>:"/\|?*`) {
|
||||||
|
t.Fatalf("extension output filename still contains illegal characters: %q", base)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestShouldStopProviderFallback(t *testing.T) {
|
func TestShouldStopProviderFallback(t *testing.T) {
|
||||||
if shouldStopProviderFallback(nil) {
|
if shouldStopProviderFallback(nil) {
|
||||||
t.Fatal("nil availability should not stop fallback")
|
t.Fatal("nil availability should not stop fallback")
|
||||||
|
|||||||
@@ -8,11 +8,35 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// allowPrivateNetworkAccess, when enabled, disables the SSRF guard that blocks
|
||||||
|
// requests resolving to private/local/loopback addresses. This is opt-in and
|
||||||
|
// intended for users who route the app's traffic through a local proxy or
|
||||||
|
// custom DNS (e.g. a local mirror of api.zarz.moe). Disabled by default.
|
||||||
|
var allowPrivateNetworkAccess atomic.Bool
|
||||||
|
|
||||||
|
// SetAllowPrivateNetwork toggles whether extensions and built-in network code
|
||||||
|
// are permitted to reach private/local network targets. Exposed to the Flutter
|
||||||
|
// layer via the platform bridge.
|
||||||
|
func SetAllowPrivateNetwork(allowed bool) {
|
||||||
|
allowPrivateNetworkAccess.Store(allowed)
|
||||||
|
if allowed {
|
||||||
|
GoLog("[HTTP] Private/local network access ENABLED (SSRF guard relaxed)\n")
|
||||||
|
} else {
|
||||||
|
GoLog("[HTTP] Private/local network access disabled (default)\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPrivateNetworkAllowed reports the current state of the private-network guard.
|
||||||
|
func IsPrivateNetworkAllowed() bool {
|
||||||
|
return allowPrivateNetworkAccess.Load()
|
||||||
|
}
|
||||||
|
|
||||||
const DefaultJSTimeout = 30 * time.Second
|
const DefaultJSTimeout = 30 * time.Second
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -303,6 +327,12 @@ func (e *RedirectBlockedError) Error() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isPrivateIP(host string) bool {
|
func isPrivateIP(host string) bool {
|
||||||
|
// Opt-in escape hatch: when the user has enabled private/local network
|
||||||
|
// access, treat every host as public so local proxies / custom DNS work.
|
||||||
|
if allowPrivateNetworkAccess.Load() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
hostLower := strings.ToLower(strings.TrimSpace(host))
|
hostLower := strings.ToLower(strings.TrimSpace(host))
|
||||||
if hostLower == "" {
|
if hostLower == "" {
|
||||||
return false
|
return false
|
||||||
@@ -465,6 +495,15 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||||
vm.Set("auth", authObj)
|
vm.Set("auth", authObj)
|
||||||
|
|
||||||
|
if r.manifest != nil && r.manifest.SignedSession != nil {
|
||||||
|
sessionObj := vm.NewObject()
|
||||||
|
sessionObj.Set("signedFetch", r.signedSessionFetch)
|
||||||
|
sessionObj.Set("completeGrant", r.signedSessionCompleteGrant)
|
||||||
|
sessionObj.Set("status", r.signedSessionStatus)
|
||||||
|
sessionObj.Set("clear", r.signedSessionClear)
|
||||||
|
vm.Set("session", sessionObj)
|
||||||
|
}
|
||||||
|
|
||||||
fileObj := vm.NewObject()
|
fileObj := vm.NewObject()
|
||||||
fileObj.Set("download", r.fileDownload)
|
fileObj.Set("download", r.fileDownload)
|
||||||
fileObj.Set("exists", r.fileExists)
|
fileObj.Set("exists", r.fileExists)
|
||||||
@@ -504,6 +543,7 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
|||||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||||
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
|
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
|
||||||
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
|
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
|
||||||
|
utilsObj.Set("decryptCTRSegments", r.decryptCTRSegments)
|
||||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||||
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
||||||
utilsObj.Set("appVersion", r.appVersion)
|
utilsObj.Set("appVersion", r.appVersion)
|
||||||
|
|||||||
@@ -158,6 +158,11 @@ func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
|
|||||||
cloned := make([]byte, len(value))
|
cloned := make([]byte, len(value))
|
||||||
copy(cloned, value)
|
copy(cloned, value)
|
||||||
return cloned, nil
|
return cloned, nil
|
||||||
|
case goja.ArrayBuffer:
|
||||||
|
src := value.Bytes()
|
||||||
|
cloned := make([]byte, len(src))
|
||||||
|
copy(cloned, src)
|
||||||
|
return cloned, nil
|
||||||
case []interface{}:
|
case []interface{}:
|
||||||
decoded := make([]byte, len(value))
|
decoded := make([]byte, len(value))
|
||||||
for i, item := range value {
|
for i, item := range value {
|
||||||
@@ -279,7 +284,9 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
|
|||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if parsedOptions.Mode != "cbc" {
|
switch parsedOptions.Mode {
|
||||||
|
case "cbc", "ctr":
|
||||||
|
default:
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
|
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
|
||||||
@@ -303,37 +310,49 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(parsedOptions.IV) != block.BlockSize() {
|
if len(parsedOptions.IV) != block.BlockSize() {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
ivLabel := "iv"
|
||||||
"success": false,
|
if parsedOptions.Mode == "ctr" {
|
||||||
"error": fmt.Sprintf("iv must be %d bytes for %s", block.BlockSize(), parsedOptions.Algorithm),
|
ivLabel = "iv (counter)"
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
data := inputData
|
|
||||||
if !decrypt && parsedOptions.Padding == "pkcs7" {
|
|
||||||
data = applyPKCS7Padding(data, block.BlockSize())
|
|
||||||
}
|
|
||||||
if len(data)%block.BlockSize() != 0 {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
output := make([]byte, len(data))
|
|
||||||
if decrypt {
|
|
||||||
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
|
||||||
if parsedOptions.Padding == "pkcs7" {
|
|
||||||
output, err = removePKCS7Padding(output, block.BlockSize())
|
|
||||||
if err != nil {
|
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
|
||||||
"success": false,
|
|
||||||
"error": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("%s must be %d bytes for %s", ivLabel, block.BlockSize(), parsedOptions.Algorithm),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var output []byte
|
||||||
|
if parsedOptions.Mode == "ctr" {
|
||||||
|
// CTR is a stream mode: encryption and decryption are identical,
|
||||||
|
// require no padding, and accept arbitrary input lengths.
|
||||||
|
output = make([]byte, len(inputData))
|
||||||
|
cipher.NewCTR(block, parsedOptions.IV).XORKeyStream(output, inputData)
|
||||||
} else {
|
} else {
|
||||||
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
data := inputData
|
||||||
|
if !decrypt && parsedOptions.Padding == "pkcs7" {
|
||||||
|
data = applyPKCS7Padding(data, block.BlockSize())
|
||||||
|
}
|
||||||
|
if len(data)%block.BlockSize() != 0 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("input length must be a multiple of %d bytes", block.BlockSize()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
output = make([]byte, len(data))
|
||||||
|
if decrypt {
|
||||||
|
cipher.NewCBCDecrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
||||||
|
if parsedOptions.Padding == "pkcs7" {
|
||||||
|
output, err = removePKCS7Padding(output, block.BlockSize())
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding)
|
encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding)
|
||||||
@@ -358,3 +377,158 @@ func (r *extensionRuntime) encryptBlockCipher(call goja.FunctionCall) goja.Value
|
|||||||
func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value {
|
||||||
return r.transformBlockCipher(call, true)
|
return r.transformBlockCipher(call, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decryptCTRSegments decrypts many independently-IV'd AES-CTR segments inside a
|
||||||
|
// single buffer in one host call. This exists to avoid thousands of JS->Go
|
||||||
|
// bridge crossings when an extension decrypts per-sample CENC media (each
|
||||||
|
// sample has its own IV/counter and cannot be merged into one stream).
|
||||||
|
//
|
||||||
|
// It is a generic primitive: any extension can use it for "one buffer, many
|
||||||
|
// CTR segments" workloads, not just Apple CENC.
|
||||||
|
//
|
||||||
|
// For best performance, pass the buffer as an ArrayBuffer/Uint8Array and set
|
||||||
|
// outputEncoding:"bytes" to get an ArrayBuffer back. This avoids base64
|
||||||
|
// encode/decode of the (potentially multi-MB) payload entirely, which is the
|
||||||
|
// dominant cost under the goja interpreter.
|
||||||
|
//
|
||||||
|
// JS signature:
|
||||||
|
// utils.decryptCTRSegments(data, {
|
||||||
|
// algorithm: "aes", // optional, default "aes"
|
||||||
|
// key: "<hex>", keyEncoding: "hex",
|
||||||
|
// segments: [ { offset: <int>, size: <int>, iv: "<base64>" }, ... ],
|
||||||
|
// ivEncoding: "base64", // encoding of each segment.iv, default base64
|
||||||
|
// inputEncoding: "bytes", // "bytes" for ArrayBuffer/Uint8Array, else base64/hex
|
||||||
|
// outputEncoding: "bytes" // "bytes" -> ArrayBuffer; else base64/hex string
|
||||||
|
// })
|
||||||
|
// Returns { success, data, segments_processed } or { success:false, error }.
|
||||||
|
func (r *extensionRuntime) decryptCTRSegments(call goja.FunctionCall) goja.Value {
|
||||||
|
fail := func(msg string) goja.Value {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": msg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return fail("data and options are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
options := parseRuntimeOptionsArgument(call, 1)
|
||||||
|
if options == nil {
|
||||||
|
return fail("options object is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
algorithm := strings.ToLower(runtimeOptionString(options, "algorithm", "aes"))
|
||||||
|
inputEncoding := strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64"))
|
||||||
|
outputEncoding := strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64"))
|
||||||
|
ivEncoding := strings.ToLower(runtimeOptionString(options, "ivEncoding", "base64"))
|
||||||
|
|
||||||
|
key, err := decodeRuntimeBytesString(
|
||||||
|
runtimeOptionString(options, "key", ""),
|
||||||
|
runtimeOptionString(options, "keyEncoding", "hex"),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fail(fmt.Sprintf("invalid key: %v", err))
|
||||||
|
}
|
||||||
|
if len(key) == 0 {
|
||||||
|
return fail("key is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var block cipher.Block
|
||||||
|
switch algorithm {
|
||||||
|
case "aes":
|
||||||
|
block, err = aes.NewCipher(key)
|
||||||
|
case "blowfish":
|
||||||
|
block, err = blowfish.NewCipher(key)
|
||||||
|
default:
|
||||||
|
return fail("unsupported algorithm: " + algorithm)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
blockSize := block.BlockSize()
|
||||||
|
|
||||||
|
// Decode the payload. For "bytes" input we operate on the raw []byte
|
||||||
|
// (ArrayBuffer/Uint8Array) without any base64 round-trip.
|
||||||
|
var data []byte
|
||||||
|
if inputEncoding == "bytes" || inputEncoding == "raw" {
|
||||||
|
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), "")
|
||||||
|
if err != nil {
|
||||||
|
return fail("invalid byte payload: " + err.Error())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), inputEncoding)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rawSegments, ok := options["segments"]
|
||||||
|
if !ok || rawSegments == nil {
|
||||||
|
return fail("segments array is required")
|
||||||
|
}
|
||||||
|
segments, ok := rawSegments.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fail("segments must be an array")
|
||||||
|
}
|
||||||
|
|
||||||
|
processed := 0
|
||||||
|
for i, rawSeg := range segments {
|
||||||
|
seg, ok := rawSeg.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fail(fmt.Sprintf("segment %d is not an object", i))
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := int(runtimeOptionInt64(seg, "offset", -1))
|
||||||
|
size := int(runtimeOptionInt64(seg, "size", -1))
|
||||||
|
if offset < 0 || size < 0 {
|
||||||
|
return fail(fmt.Sprintf("segment %d has invalid offset/size", i))
|
||||||
|
}
|
||||||
|
if size == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if offset+size > len(data) {
|
||||||
|
return fail(fmt.Sprintf("segment %d out of bounds (offset=%d size=%d len=%d)", i, offset, size, len(data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
iv, err := decodeRuntimeBytesString(runtimeOptionString(seg, "iv", ""), ivEncoding)
|
||||||
|
if err != nil {
|
||||||
|
return fail(fmt.Sprintf("segment %d has invalid iv: %v", i, err))
|
||||||
|
}
|
||||||
|
if len(iv) != blockSize {
|
||||||
|
// Accept short IVs by left-aligning into a block-sized counter
|
||||||
|
// (CENC commonly uses 8-byte IVs for a 16-byte AES counter).
|
||||||
|
if len(iv) > blockSize {
|
||||||
|
return fail(fmt.Sprintf("segment %d iv longer than block size (%d > %d)", i, len(iv), blockSize))
|
||||||
|
}
|
||||||
|
padded := make([]byte, blockSize)
|
||||||
|
copy(padded, iv)
|
||||||
|
iv = padded
|
||||||
|
}
|
||||||
|
|
||||||
|
segData := data[offset : offset+size]
|
||||||
|
cipher.NewCTR(block, iv).XORKeyStream(segData, segData)
|
||||||
|
processed++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return raw bytes as an ArrayBuffer when requested (zero-copy-ish, no
|
||||||
|
// base64). Otherwise fall back to an encoded string.
|
||||||
|
if outputEncoding == "bytes" || outputEncoding == "raw" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"data": r.vm.NewArrayBuffer(data),
|
||||||
|
"segments_processed": processed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := encodeRuntimeBytes(data, outputEncoding)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"data": encoded,
|
||||||
|
"segments_processed": processed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -183,3 +183,303 @@ func TestExtensionRuntime_BlockCipherCBCSupportsAES(t *testing.T) {
|
|||||||
t.Fatalf("unexpected decrypted value: %q", result.String())
|
t.Fatalf("unexpected decrypted value: %q", result.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_BlockCipherCTRSupportsAES(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, false)
|
||||||
|
|
||||||
|
// NIST SP 800-38A, F.5.1 CTR-AES128.Encrypt test vector.
|
||||||
|
// Key: 2b7e151628aed2a6abf7158809cf4f3c
|
||||||
|
// Counter: f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff
|
||||||
|
// Plaintext: 6bc1bee22e409f96e93d7e117393172a (block 1)
|
||||||
|
// Ciphertext: 874d6191b620e3261bef6864990db6ce (block 1)
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var options = {
|
||||||
|
algorithm: "aes",
|
||||||
|
mode: "ctr",
|
||||||
|
key: "2b7e151628aed2a6abf7158809cf4f3c",
|
||||||
|
keyEncoding: "hex",
|
||||||
|
iv: "f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff",
|
||||||
|
ivEncoding: "hex",
|
||||||
|
inputEncoding: "hex",
|
||||||
|
outputEncoding: "hex"
|
||||||
|
};
|
||||||
|
var enc = utils.encryptBlockCipher("6bc1bee22e409f96e93d7e117393172a", options);
|
||||||
|
if (!enc.success) throw new Error(enc.error);
|
||||||
|
// CTR is symmetric: decrypt is the same transform as encrypt.
|
||||||
|
var dec = utils.decryptBlockCipher(enc.data, options);
|
||||||
|
if (!dec.success) throw new Error(dec.error);
|
||||||
|
return JSON.stringify({enc: enc.data, dec: dec.data});
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("aes ctr block cipher failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := decodeJSONResult[struct {
|
||||||
|
Enc string `json:"enc"`
|
||||||
|
Dec string `json:"dec"`
|
||||||
|
}](t, result)
|
||||||
|
|
||||||
|
if decoded.Enc != "874d6191b620e3261bef6864990db6ce" {
|
||||||
|
t.Fatalf("ctr ciphertext = %q, want NIST vector 874d6191b620e3261bef6864990db6ce", decoded.Enc)
|
||||||
|
}
|
||||||
|
if decoded.Dec != "6bc1bee22e409f96e93d7e117393172a" {
|
||||||
|
t.Fatalf("ctr round-trip dec = %q", decoded.Dec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_BlockCipherCTRHandlesNonBlockLength(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, false)
|
||||||
|
|
||||||
|
// CTR is a stream mode, so arbitrary (non-16-byte-aligned) input lengths
|
||||||
|
// must round-trip without any padding.
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var options = {
|
||||||
|
algorithm: "aes",
|
||||||
|
mode: "ctr",
|
||||||
|
key: "000102030405060708090a0b0c0d0e0f",
|
||||||
|
keyEncoding: "hex",
|
||||||
|
iv: "0f0e0d0c0b0a09080706050403020100",
|
||||||
|
ivEncoding: "hex",
|
||||||
|
inputEncoding: "utf8",
|
||||||
|
outputEncoding: "base64"
|
||||||
|
};
|
||||||
|
var enc = utils.encryptBlockCipher("stream ctr of odd length", options);
|
||||||
|
if (!enc.success) throw new Error(enc.error);
|
||||||
|
var dec = utils.decryptBlockCipher(enc.data, {
|
||||||
|
algorithm: "aes",
|
||||||
|
mode: "ctr",
|
||||||
|
key: options.key,
|
||||||
|
keyEncoding: options.keyEncoding,
|
||||||
|
iv: options.iv,
|
||||||
|
ivEncoding: options.ivEncoding,
|
||||||
|
inputEncoding: "base64",
|
||||||
|
outputEncoding: "utf8"
|
||||||
|
});
|
||||||
|
if (!dec.success) throw new Error(dec.error);
|
||||||
|
return dec.data;
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("aes ctr stream length failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.String() != "stream ctr of odd length" {
|
||||||
|
t.Fatalf("unexpected ctr decrypted value: %q", result.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_BlockCipherCTRRejectsBadIV(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, false)
|
||||||
|
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var res = utils.encryptBlockCipher("00112233", {
|
||||||
|
algorithm: "aes",
|
||||||
|
mode: "ctr",
|
||||||
|
key: "000102030405060708090a0b0c0d0e0f",
|
||||||
|
keyEncoding: "hex",
|
||||||
|
iv: "0001",
|
||||||
|
ivEncoding: "hex",
|
||||||
|
inputEncoding: "hex",
|
||||||
|
outputEncoding: "hex"
|
||||||
|
});
|
||||||
|
return JSON.stringify({success: res.success, error: res.error || ""});
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("aes ctr bad iv eval failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := decodeJSONResult[struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}](t, result)
|
||||||
|
|
||||||
|
if decoded.Success {
|
||||||
|
t.Fatal("expected failure for undersized CTR iv")
|
||||||
|
}
|
||||||
|
if decoded.Error == "" {
|
||||||
|
t.Fatal("expected error message for undersized CTR iv")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_DecryptCTRSegmentsMatchesPerSegment(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, false)
|
||||||
|
|
||||||
|
// Build a buffer of 3 segments encrypted with distinct 8-byte IVs (CENC
|
||||||
|
// style), then verify the batch primitive decrypts all of them in one call,
|
||||||
|
// matching what per-segment decryptBlockCipher would produce.
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var keyHex = "000102030405060708090a0b0c0d0e0f";
|
||||||
|
function b64(bytes){return utils.base64Encode(utils.toHex ? bytes : bytes);}
|
||||||
|
|
||||||
|
// segment plaintexts (hex) and 8-byte IVs (hex)
|
||||||
|
var segs = [
|
||||||
|
{ pt: "11111111111111111111", iv: "0000000000000001" },
|
||||||
|
{ pt: "2222222222", iv: "0000000000000002" },
|
||||||
|
{ pt: "333333333333333333333333", iv: "00000000000000ff" }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Encrypt each segment individually using single-shot CTR with a
|
||||||
|
// 16-byte counter (8-byte iv left-aligned), producing ciphertext hex.
|
||||||
|
function ivToB64(ivHex){
|
||||||
|
// pad 8-byte hex iv to 16 bytes then base64
|
||||||
|
var full = ivHex + "00000000000000000000000000000000".slice(ivHex.length);
|
||||||
|
return utils.base64Encode(utils.hexToBytes ? utils.hexToBytes(full) : full);
|
||||||
|
}
|
||||||
|
|
||||||
|
var cipherHex = "";
|
||||||
|
var offsets = [];
|
||||||
|
var off = 0;
|
||||||
|
var ivB64s = [];
|
||||||
|
for (var i=0;i<segs.length;i++){
|
||||||
|
var ivFullHex = (segs[i].iv + "00000000000000000000000000000000").slice(0,32);
|
||||||
|
var enc = utils.encryptBlockCipher(segs[i].pt, {
|
||||||
|
algorithm:"aes", mode:"ctr", key:keyHex, keyEncoding:"hex",
|
||||||
|
iv: ivFullHex, ivEncoding:"hex",
|
||||||
|
inputEncoding:"hex", outputEncoding:"hex"
|
||||||
|
});
|
||||||
|
if(!enc.success) throw new Error("enc seg "+i+": "+enc.error);
|
||||||
|
cipherHex += enc.data;
|
||||||
|
var sz = segs[i].pt.length/2;
|
||||||
|
offsets.push({offset: off, size: sz, ivHex: ivFullHex});
|
||||||
|
off += sz;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now decrypt the whole concatenated buffer in ONE batch call.
|
||||||
|
var segments = offsets.map(function(o){
|
||||||
|
return { offset:o.offset, size:o.size, iv:o.ivHex };
|
||||||
|
});
|
||||||
|
var batch = utils.decryptCTRSegments(cipherHex, {
|
||||||
|
algorithm:"aes", key:keyHex, keyEncoding:"hex",
|
||||||
|
segments: segments, ivEncoding:"hex",
|
||||||
|
inputEncoding:"hex", outputEncoding:"hex"
|
||||||
|
});
|
||||||
|
if(!batch.success) throw new Error("batch: "+batch.error);
|
||||||
|
|
||||||
|
var expected = "";
|
||||||
|
for (var j=0;j<segs.length;j++) expected += segs[j].pt;
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
out: batch.data,
|
||||||
|
expected: expected,
|
||||||
|
processed: batch.segments_processed
|
||||||
|
});
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("batch CTR eval failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := decodeJSONResult[struct {
|
||||||
|
Out string `json:"out"`
|
||||||
|
Expected string `json:"expected"`
|
||||||
|
Processed int `json:"processed"`
|
||||||
|
}](t, result)
|
||||||
|
|
||||||
|
if decoded.Out != decoded.Expected {
|
||||||
|
t.Fatalf("batch decrypt mismatch:\n got=%s\nwant=%s", decoded.Out, decoded.Expected)
|
||||||
|
}
|
||||||
|
if decoded.Processed != 3 {
|
||||||
|
t.Fatalf("segments_processed = %d, want 3", decoded.Processed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_DecryptCTRSegmentsRejectsOutOfBounds(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, false)
|
||||||
|
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var res = utils.decryptCTRSegments("00112233", {
|
||||||
|
algorithm:"aes", key:"000102030405060708090a0b0c0d0e0f", keyEncoding:"hex",
|
||||||
|
inputEncoding:"hex", outputEncoding:"hex",
|
||||||
|
ivEncoding:"hex",
|
||||||
|
segments: [ { offset: 0, size: 99, iv: "00000000000000000000000000000000" } ]
|
||||||
|
});
|
||||||
|
return JSON.stringify({ success: res.success, error: res.error || "" });
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("oob eval failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := decodeJSONResult[struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}](t, result)
|
||||||
|
|
||||||
|
if decoded.Success {
|
||||||
|
t.Fatal("expected out-of-bounds segment to fail")
|
||||||
|
}
|
||||||
|
if decoded.Error == "" {
|
||||||
|
t.Fatal("expected error message for out-of-bounds segment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtensionRuntime_DecryptCTRSegmentsRawBytes(t *testing.T) {
|
||||||
|
vm := newBinaryTestRuntime(t, false)
|
||||||
|
|
||||||
|
// Verify the zero-base64 path: pass an ArrayBuffer in, request bytes out,
|
||||||
|
// and confirm round-trip correctness against single-shot CTR.
|
||||||
|
result, err := vm.RunString(`
|
||||||
|
(function() {
|
||||||
|
var keyHex = "000102030405060708090a0b0c0d0e0f";
|
||||||
|
var ivFullHex = "0000000000000001" + "00000000000000000000000000000000".slice(16);
|
||||||
|
|
||||||
|
// Plaintext as a Uint8Array of 20 bytes.
|
||||||
|
var pt = new Uint8Array(20);
|
||||||
|
for (var i = 0; i < pt.length; i++) pt[i] = (i * 7 + 3) & 0xff;
|
||||||
|
|
||||||
|
// Encrypt single-shot to get ciphertext (hex output for clarity).
|
||||||
|
var ptHex = "";
|
||||||
|
for (var j = 0; j < pt.length; j++) { var h = pt[j].toString(16); ptHex += (h.length === 1 ? "0" : "") + h; }
|
||||||
|
var enc = utils.encryptBlockCipher(ptHex, {
|
||||||
|
algorithm:"aes", mode:"ctr", key:keyHex, keyEncoding:"hex",
|
||||||
|
iv: ivFullHex, ivEncoding:"hex", inputEncoding:"hex", outputEncoding:"base64"
|
||||||
|
});
|
||||||
|
if (!enc.success) throw new Error("enc: " + enc.error);
|
||||||
|
|
||||||
|
// Decode ciphertext base64 into a Uint8Array to feed the raw path.
|
||||||
|
var cipherBytes = utils.base64Decode ? null : null;
|
||||||
|
// Build ArrayBuffer from base64 via Uint8Array manually:
|
||||||
|
var b64 = enc.data;
|
||||||
|
var bin = (typeof atob === "function") ? null : null;
|
||||||
|
|
||||||
|
// Simpler: ask the host to give us bytes by decrypting nothing is hard,
|
||||||
|
// so just pass the base64 ciphertext through decryptCTRSegments using
|
||||||
|
// base64 input but bytes output, then re-run with bytes input.
|
||||||
|
var step1 = utils.decryptCTRSegments(b64, {
|
||||||
|
algorithm:"aes", key:keyHex, keyEncoding:"hex",
|
||||||
|
segments: [ { offset:0, size:20, iv: ivFullHex } ],
|
||||||
|
ivEncoding:"hex", inputEncoding:"base64", outputEncoding:"bytes"
|
||||||
|
});
|
||||||
|
if (!step1.success) throw new Error("step1: " + step1.error);
|
||||||
|
if (typeof step1.data === "string") throw new Error("expected ArrayBuffer output, got string");
|
||||||
|
|
||||||
|
var outArr = new Uint8Array(step1.data);
|
||||||
|
var outHex = "";
|
||||||
|
for (var k = 0; k < outArr.length; k++) { var hh = outArr[k].toString(16); outHex += (hh.length === 1 ? "0" : "") + hh; }
|
||||||
|
return JSON.stringify({ out: outHex, expected: ptHex, len: outArr.length });
|
||||||
|
})()
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("raw-bytes eval failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := decodeJSONResult[struct {
|
||||||
|
Out string `json:"out"`
|
||||||
|
Expected string `json:"expected"`
|
||||||
|
Len int `json:"len"`
|
||||||
|
}](t, result)
|
||||||
|
|
||||||
|
if decoded.Out != decoded.Expected {
|
||||||
|
t.Fatalf("raw-bytes decrypt mismatch:\n got=%s\nwant=%s", decoded.Out, decoded.Expected)
|
||||||
|
}
|
||||||
|
if decoded.Len != 20 {
|
||||||
|
t.Fatalf("output length = %d, want 20", decoded.Len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -370,7 +370,6 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
|
|||||||
var totalSize int64
|
var totalSize int64
|
||||||
contentRange := probeResp.Header.Get("Content-Range")
|
contentRange := probeResp.Header.Get("Content-Range")
|
||||||
if contentRange != "" {
|
if contentRange != "" {
|
||||||
// Format: "bytes 0-1/12345"
|
|
||||||
if idx := strings.LastIndex(contentRange, "/"); idx >= 0 {
|
if idx := strings.LastIndex(contentRange, "/"); idx >= 0 {
|
||||||
sizeStr := contentRange[idx+1:]
|
sizeStr := contentRange[idx+1:]
|
||||||
if sizeStr != "*" {
|
if sizeStr != "*" {
|
||||||
@@ -457,7 +456,6 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
|
|||||||
break // Success
|
break // Success
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-success status
|
|
||||||
io.Copy(io.Discard, chunkResp.Body)
|
io.Copy(io.Discard, chunkResp.Body)
|
||||||
chunkResp.Body.Close()
|
chunkResp.Body.Close()
|
||||||
|
|
||||||
@@ -474,7 +472,6 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read chunk body and write to file
|
|
||||||
chunkWritten := int64(0)
|
chunkWritten := int64(0)
|
||||||
for {
|
for {
|
||||||
nr, er := chunkResp.Body.Read(buf)
|
nr, er := chunkResp.Body.Read(buf)
|
||||||
@@ -663,7 +660,6 @@ func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
|
|||||||
"error": "offset must be >= 0",
|
"error": "offset must be >= 0",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.Open(fullPath)
|
file, err := os.Open(fullPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -716,6 +712,20 @@ func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.EqualFold(strings.TrimSpace(encoding), "bytes") ||
|
||||||
|
strings.EqualFold(strings.TrimSpace(encoding), "raw") {
|
||||||
|
// Return raw bytes as an ArrayBuffer to avoid base64 encode/decode of
|
||||||
|
// large payloads under the goja interpreter.
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"data": r.vm.NewArrayBuffer(data),
|
||||||
|
"bytes_read": len(data),
|
||||||
|
"offset": offset,
|
||||||
|
"size": size,
|
||||||
|
"eof": offset+int64(len(data)) >= size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
encoded, err := encodeRuntimeBytes(data, encoding)
|
encoded, err := encodeRuntimeBytes(data, encoding)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
@@ -733,7 +743,6 @@ func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
|
|||||||
"eof": offset+int64(len(data)) >= size,
|
"eof": offset+int64(len(data)) >= size,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) < 2 {
|
if len(call.Arguments) < 2 {
|
||||||
return r.vm.ToValue(map[string]interface{}{
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
|||||||
@@ -0,0 +1,664 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
)
|
||||||
|
|
||||||
|
const signedSessionRefreshSkew = time.Hour
|
||||||
|
|
||||||
|
var (
|
||||||
|
pendingSignedSessionGrants = make(map[string]string)
|
||||||
|
pendingSignedSessionGrantsMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
type signedSessionRecord struct {
|
||||||
|
InstallID string `json:"install_id"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
SessionSecret string `json:"session_secret,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
Namespace string `json:"namespace,omitempty"`
|
||||||
|
BaseURL string `json:"base_url,omitempty"`
|
||||||
|
AppVersion string `json:"app_version,omitempty"`
|
||||||
|
Platform string `json:"platform,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type signedSessionExchangeResponse struct {
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
SessionSecret string `json:"session_secret,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
ChallengeID string `json:"challenge_id,omitempty"`
|
||||||
|
ChallengeURL string `json:"challenge_url,omitempty"`
|
||||||
|
AuthURL string `json:"auth_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedSessionConfigWithDefaults(config *SignedSessionConfig) SignedSessionConfig {
|
||||||
|
if config == nil {
|
||||||
|
return SignedSessionConfig{}
|
||||||
|
}
|
||||||
|
resolved := *config
|
||||||
|
if resolved.AppVersion == "" {
|
||||||
|
resolved.AppVersion = "ext-1.0"
|
||||||
|
}
|
||||||
|
if resolved.Platform == "" {
|
||||||
|
resolved.Platform = "extension"
|
||||||
|
}
|
||||||
|
if resolved.CallbackURL == "" {
|
||||||
|
resolved.CallbackURL = "spotiflac://session-grant"
|
||||||
|
}
|
||||||
|
if resolved.SchemeLabel == "" {
|
||||||
|
resolved.SchemeLabel = "SPOTIFLAC-HMAC-V1"
|
||||||
|
}
|
||||||
|
if resolved.HeaderPrefix == "" {
|
||||||
|
resolved.HeaderPrefix = "X-Sig-"
|
||||||
|
}
|
||||||
|
if resolved.TimeWindowSeconds <= 0 {
|
||||||
|
resolved.TimeWindowSeconds = 300
|
||||||
|
}
|
||||||
|
if resolved.Endpoints.Bootstrap == "" {
|
||||||
|
resolved.Endpoints.Bootstrap = "/bootstrap"
|
||||||
|
}
|
||||||
|
if resolved.Endpoints.Challenge == "" {
|
||||||
|
resolved.Endpoints.Challenge = "/challenge"
|
||||||
|
}
|
||||||
|
if resolved.Endpoints.Exchange == "" {
|
||||||
|
resolved.Endpoints.Exchange = "/session/exchange"
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionFilePath(config SignedSessionConfig) (string, error) {
|
||||||
|
namespace := sanitizeSignedSessionNamespace(config.Namespace)
|
||||||
|
if namespace == "" {
|
||||||
|
return "", fmt.Errorf("signed session namespace is empty")
|
||||||
|
}
|
||||||
|
baseDir := filepath.Dir(r.dataDir)
|
||||||
|
if baseDir == "." || baseDir == "" {
|
||||||
|
baseDir = r.dataDir
|
||||||
|
}
|
||||||
|
dir := filepath.Join(baseDir, "signed_sessions")
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
scope := strings.Join([]string{
|
||||||
|
namespace,
|
||||||
|
strings.TrimSpace(strings.ToLower(config.BaseURL)),
|
||||||
|
strings.TrimSpace(strings.ToLower(config.AppVersion)),
|
||||||
|
strings.TrimSpace(strings.ToLower(config.Platform)),
|
||||||
|
}, "\n")
|
||||||
|
sum := sha256.Sum256([]byte(scope))
|
||||||
|
return filepath.Join(dir, namespace+"-"+hex.EncodeToString(sum[:])[:16]+".json"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeSignedSessionNamespace(namespace string) string {
|
||||||
|
namespace = strings.TrimSpace(strings.ToLower(namespace))
|
||||||
|
var b strings.Builder
|
||||||
|
for _, ch := range namespace {
|
||||||
|
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' {
|
||||||
|
b.WriteRune(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(b.String(), ".-_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) loadSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
|
||||||
|
path, err := r.signedSessionFilePath(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
record := &signedSessionRecord{}
|
||||||
|
if data, err := os.ReadFile(path); err == nil {
|
||||||
|
_ = json.Unmarshal(data, record)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(record.InstallID) == "" {
|
||||||
|
record.InstallID = randomHex(16)
|
||||||
|
}
|
||||||
|
normalizeSignedSessionRecordScope(config, record)
|
||||||
|
if err := r.saveSignedSession(config, record); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSignedSessionRecordScope(config SignedSessionConfig, record *signedSessionRecord) {
|
||||||
|
namespace := sanitizeSignedSessionNamespace(config.Namespace)
|
||||||
|
baseURL := strings.TrimSpace(config.BaseURL)
|
||||||
|
appVersion := strings.TrimSpace(config.AppVersion)
|
||||||
|
platform := strings.TrimSpace(config.Platform)
|
||||||
|
if record.Namespace == "" && record.BaseURL == "" && record.AppVersion == "" && record.Platform == "" {
|
||||||
|
record.Namespace = namespace
|
||||||
|
record.BaseURL = baseURL
|
||||||
|
record.AppVersion = appVersion
|
||||||
|
record.Platform = platform
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if record.Namespace != namespace ||
|
||||||
|
record.BaseURL != baseURL ||
|
||||||
|
record.AppVersion != appVersion ||
|
||||||
|
record.Platform != platform {
|
||||||
|
record.SessionID = ""
|
||||||
|
record.SessionSecret = ""
|
||||||
|
record.ExpiresAt = ""
|
||||||
|
}
|
||||||
|
record.Namespace = namespace
|
||||||
|
record.BaseURL = baseURL
|
||||||
|
record.AppVersion = appVersion
|
||||||
|
record.Platform = platform
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) saveSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
|
||||||
|
path, err := r.signedSessionFilePath(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(record, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomHex(bytesLen int) string {
|
||||||
|
buf := make([]byte, bytesLen)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSignedSessionTime(value string) (time.Time, bool) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
layouts := []string{
|
||||||
|
time.RFC3339Nano,
|
||||||
|
time.RFC3339,
|
||||||
|
"2006-01-02T15:04:05.000Z",
|
||||||
|
}
|
||||||
|
for _, layout := range layouts {
|
||||||
|
if parsed, err := time.Parse(layout, value); err == nil {
|
||||||
|
return parsed, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionStatus(call goja.FunctionCall) goja.Value {
|
||||||
|
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||||
|
if config.Namespace == "" || config.BaseURL == "" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": "signedSession is not configured"})
|
||||||
|
}
|
||||||
|
record, err := r.loadSignedSession(config)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"authenticated": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
authenticated := record.SessionID != "" && record.SessionSecret != ""
|
||||||
|
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok && time.Now().After(expiresAt) {
|
||||||
|
authenticated = false
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"authenticated": authenticated,
|
||||||
|
"expires_at": record.ExpiresAt,
|
||||||
|
"install_id": record.InstallID,
|
||||||
|
"session_id": record.SessionID,
|
||||||
|
"app_version": config.AppVersion,
|
||||||
|
"platform": config.Platform,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionClear(call goja.FunctionCall) goja.Value {
|
||||||
|
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||||
|
record, err := r.loadSignedSession(config)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
record.SessionID = ""
|
||||||
|
record.SessionSecret = ""
|
||||||
|
record.ExpiresAt = ""
|
||||||
|
if err := r.saveSignedSession(config, record); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
ClearPendingAuthRequest(r.extensionID)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionCompleteGrant(call goja.FunctionCall) goja.Value {
|
||||||
|
grant := ""
|
||||||
|
if len(call.Arguments) > 0 {
|
||||||
|
grant = strings.TrimSpace(call.Arguments[0].String())
|
||||||
|
}
|
||||||
|
if grant == "" {
|
||||||
|
pendingSignedSessionGrantsMu.Lock()
|
||||||
|
grant = pendingSignedSessionGrants[r.extensionID]
|
||||||
|
delete(pendingSignedSessionGrants, r.extensionID)
|
||||||
|
pendingSignedSessionGrantsMu.Unlock()
|
||||||
|
}
|
||||||
|
if grant == "" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"success": false, "error": "no pending grant"})
|
||||||
|
}
|
||||||
|
if err := r.exchangeSignedSessionGrant(grant); err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"success": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
ClearPendingAuthRequest(r.extensionID)
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) exchangeSignedSessionGrant(grant string) error {
|
||||||
|
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||||
|
record, err := r.loadSignedSession(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
endpoint, err := signedSessionURL(config, config.Endpoints.Exchange)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"grant": grant,
|
||||||
|
"install_id": record.InstallID,
|
||||||
|
"app_version": config.AppVersion,
|
||||||
|
"platform": config.Platform,
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, err := readExtensionHTTPResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("session exchange failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var exchanged signedSessionExchangeResponse
|
||||||
|
if err := json.Unmarshal(respBody, &exchanged); err != nil {
|
||||||
|
return fmt.Errorf("invalid session exchange response: %w", err)
|
||||||
|
}
|
||||||
|
if exchanged.SessionID == "" || exchanged.SessionSecret == "" || exchanged.ExpiresAt == "" {
|
||||||
|
return fmt.Errorf("session exchange response missing session fields")
|
||||||
|
}
|
||||||
|
record.SessionID = exchanged.SessionID
|
||||||
|
record.SessionSecret = exchanged.SessionSecret
|
||||||
|
record.ExpiresAt = exchanged.ExpiresAt
|
||||||
|
return r.saveSignedSession(config, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value {
|
||||||
|
if len(call.Arguments) < 2 {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": "method and path are required"})
|
||||||
|
}
|
||||||
|
config := signedSessionConfigWithDefaults(r.manifest.SignedSession)
|
||||||
|
if config.Namespace == "" || config.BaseURL == "" {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": "signedSession is not configured"})
|
||||||
|
}
|
||||||
|
method := strings.ToUpper(strings.TrimSpace(call.Arguments[0].String()))
|
||||||
|
requestPath := call.Arguments[1].String()
|
||||||
|
body := []byte{}
|
||||||
|
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||||
|
switch v := call.Arguments[2].Export().(type) {
|
||||||
|
case string:
|
||||||
|
body = []byte(v)
|
||||||
|
case map[string]interface{}, []interface{}:
|
||||||
|
encoded, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
body = encoded
|
||||||
|
default:
|
||||||
|
body = []byte(call.Arguments[2].String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extraHeaders := map[string]string{}
|
||||||
|
if len(call.Arguments) > 3 && !goja.IsUndefined(call.Arguments[3]) && !goja.IsNull(call.Arguments[3]) {
|
||||||
|
if h, ok := call.Arguments[3].Export().(map[string]interface{}); ok {
|
||||||
|
for k, v := range h {
|
||||||
|
extraHeaders[k] = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := r.ensureSignedSession(config)
|
||||||
|
if err != nil {
|
||||||
|
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
|
||||||
|
return r.signedSessionVerificationRequiredValue(authURL)
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, respBody, respHeaders, err := r.doSignedSessionRequest(config, record, method, requestPath, body, extraHeaders)
|
||||||
|
if err != nil {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{"ok": false, "error": err.Error()})
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusPreconditionRequired {
|
||||||
|
record.SessionID = ""
|
||||||
|
record.SessionSecret = ""
|
||||||
|
record.ExpiresAt = ""
|
||||||
|
_ = r.saveSignedSession(config, record)
|
||||||
|
if authURL := r.startSignedSessionVerification(config, ""); authURL != "" {
|
||||||
|
return r.signedSessionVerificationRequiredValue(authURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"statusCode": resp.StatusCode,
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
"ok": resp.StatusCode >= 200 && resp.StatusCode < 300,
|
||||||
|
"url": resp.Request.URL.String(),
|
||||||
|
"body": string(respBody),
|
||||||
|
"headers": respHeaders,
|
||||||
|
"retryAfterSeconds": signedSessionRetryAfterSeconds(resp),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) signedSessionVerificationRequiredValue(authURL string) goja.Value {
|
||||||
|
return r.vm.ToValue(map[string]interface{}{
|
||||||
|
"ok": false,
|
||||||
|
"needsVerification": true,
|
||||||
|
"error": "VERIFY_REQUIRED",
|
||||||
|
"open_auth_url": authURL,
|
||||||
|
"auth_url": authURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) ensureSignedSession(config SignedSessionConfig) (*signedSessionRecord, error) {
|
||||||
|
record, err := r.loadSignedSession(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if record.SessionID == "" || record.SessionSecret == "" {
|
||||||
|
return nil, fmt.Errorf("signed session is not authenticated")
|
||||||
|
}
|
||||||
|
if expiresAt, ok := parseSignedSessionTime(record.ExpiresAt); ok {
|
||||||
|
if time.Now().After(expiresAt) {
|
||||||
|
record.SessionID = ""
|
||||||
|
record.SessionSecret = ""
|
||||||
|
record.ExpiresAt = ""
|
||||||
|
_ = r.saveSignedSession(config, record)
|
||||||
|
return nil, fmt.Errorf("signed session expired")
|
||||||
|
}
|
||||||
|
if config.Endpoints.Refresh != "" && time.Until(expiresAt) <= signedSessionRefreshSkew {
|
||||||
|
_ = r.refreshSignedSession(config, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) refreshSignedSession(config SignedSessionConfig, record *signedSessionRecord) error {
|
||||||
|
body, _ := json.Marshal(map[string]string{"install_id": record.InstallID})
|
||||||
|
resp, respBody, _, err := r.doSignedSessionRequest(config, record, http.MethodPost, config.Endpoints.Refresh, body, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("session refresh failed: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var refreshed signedSessionExchangeResponse
|
||||||
|
if err := json.Unmarshal(respBody, &refreshed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
if refreshed.SessionID != "" {
|
||||||
|
record.SessionID = refreshed.SessionID
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if refreshed.SessionSecret != "" {
|
||||||
|
record.SessionSecret = refreshed.SessionSecret
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if refreshed.ExpiresAt != "" && refreshed.ExpiresAt != record.ExpiresAt {
|
||||||
|
record.ExpiresAt = refreshed.ExpiresAt
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
return r.saveSignedSession(config, record)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) startSignedSessionVerification(config SignedSessionConfig, reason string) string {
|
||||||
|
record, err := r.loadSignedSession(config)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
bootstrapURL, err := signedSessionURL(config, config.Endpoints.Bootstrap)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parsed, _ := url.Parse(bootstrapURL)
|
||||||
|
query := parsed.Query()
|
||||||
|
query.Set("app_version", config.AppVersion)
|
||||||
|
query.Set("install_id", record.InstallID)
|
||||||
|
parsed.RawQuery = query.Encode()
|
||||||
|
req, err := http.NewRequest(http.MethodGet, parsed.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxExtensionHTTPResponseBytes))
|
||||||
|
if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var boot signedSessionExchangeResponse
|
||||||
|
if err := json.Unmarshal(body, &boot); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if boot.SessionID != "" && boot.SessionSecret != "" && boot.ExpiresAt != "" {
|
||||||
|
record.SessionID = boot.SessionID
|
||||||
|
record.SessionSecret = boot.SessionSecret
|
||||||
|
record.ExpiresAt = boot.ExpiresAt
|
||||||
|
_ = r.saveSignedSession(config, record)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
authURL := boot.AuthURL
|
||||||
|
if authURL == "" && boot.ChallengeURL != "" {
|
||||||
|
authURL = boot.ChallengeURL
|
||||||
|
}
|
||||||
|
if authURL == "" && boot.ChallengeID != "" {
|
||||||
|
authURL = r.buildSignedSessionChallengeURL(config, boot.ChallengeID)
|
||||||
|
}
|
||||||
|
if authURL != "" {
|
||||||
|
pendingAuthRequestsMu.Lock()
|
||||||
|
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
|
||||||
|
ExtensionID: r.extensionID,
|
||||||
|
AuthURL: authURL,
|
||||||
|
CallbackURL: config.CallbackURL,
|
||||||
|
}
|
||||||
|
pendingAuthRequestsMu.Unlock()
|
||||||
|
}
|
||||||
|
return authURL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) buildSignedSessionChallengeURL(config SignedSessionConfig, challengeID string) string {
|
||||||
|
challengeURL, err := signedSessionURL(config, config.Endpoints.Challenge)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(challengeURL)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
callback, err := url.Parse(config.CallbackURL)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
q := callback.Query()
|
||||||
|
q.Set("cb_version", "v2grant")
|
||||||
|
q.Set("state", r.extensionID)
|
||||||
|
callback.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
query := parsed.Query()
|
||||||
|
query.Set("id", challengeID)
|
||||||
|
query.Set("cb", callback.String())
|
||||||
|
parsed.RawQuery = query.Encode()
|
||||||
|
return parsed.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedSessionURL(config SignedSessionConfig, endpoint string) (string, error) {
|
||||||
|
base, err := url.Parse(strings.TrimRight(config.BaseURL, "/") + "/")
|
||||||
|
if err != nil || base.Scheme != "https" || base.Host == "" {
|
||||||
|
return "", fmt.Errorf("invalid signed session baseUrl")
|
||||||
|
}
|
||||||
|
endpoint = strings.TrimSpace(endpoint)
|
||||||
|
if endpoint == "" {
|
||||||
|
return "", fmt.Errorf("signed session endpoint is empty")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(endpoint, "https://") {
|
||||||
|
return endpoint, nil
|
||||||
|
}
|
||||||
|
endpoint = strings.TrimLeft(endpoint, "/")
|
||||||
|
ref, _ := url.Parse(endpoint)
|
||||||
|
return base.ResolveReference(ref).String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *extensionRuntime) doSignedSessionRequest(
|
||||||
|
config SignedSessionConfig,
|
||||||
|
record *signedSessionRecord,
|
||||||
|
method string,
|
||||||
|
requestPath string,
|
||||||
|
body []byte,
|
||||||
|
extraHeaders map[string]string,
|
||||||
|
) (*http.Response, []byte, map[string]interface{}, error) {
|
||||||
|
fullURL, err := signedSessionURL(config, requestPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(fullURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
ts := time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
|
||||||
|
nonce := randomHex(12)
|
||||||
|
bodyHashBytes := sha256.Sum256(body)
|
||||||
|
bodyHash := hex.EncodeToString(bodyHashBytes[:])
|
||||||
|
parsedTs, _ := time.Parse("2006-01-02T15:04:05.000Z", ts)
|
||||||
|
window := parsedTs.Unix() / int64(config.TimeWindowSeconds)
|
||||||
|
rollingInput := fmt.Sprintf("%d:%s", window, record.SessionID)
|
||||||
|
rk := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(record.SessionSecret), []byte(rollingInput)))
|
||||||
|
signingInput := strings.Join([]string{
|
||||||
|
config.SchemeLabel,
|
||||||
|
method,
|
||||||
|
parsed.EscapedPath(),
|
||||||
|
"",
|
||||||
|
bodyHash,
|
||||||
|
ts,
|
||||||
|
nonce,
|
||||||
|
record.SessionID,
|
||||||
|
config.AppVersion,
|
||||||
|
config.Platform,
|
||||||
|
}, "\n")
|
||||||
|
sig := base64.RawURLEncoding.EncodeToString(hmacSHA256Bytes([]byte(rk), []byte(signingInput)))
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, fullURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
req = r.bindDownloadCancelContext(req)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if len(body) > 0 {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/"+config.AppVersion)
|
||||||
|
prefix := config.HeaderPrefix
|
||||||
|
req.Header.Set(prefix+"Session", record.SessionID)
|
||||||
|
req.Header.Set(prefix+"Timestamp", ts)
|
||||||
|
req.Header.Set(prefix+"Nonce", nonce)
|
||||||
|
req.Header.Set(prefix+"Body-SHA256", bodyHash)
|
||||||
|
req.Header.Set(prefix+"Signature", sig)
|
||||||
|
req.Header.Set(prefix+"App-Version", config.AppVersion)
|
||||||
|
req.Header.Set(prefix+"Platform", config.Platform)
|
||||||
|
for k, v := range extraHeaders {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := r.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, err := readExtensionHTTPResponseBody(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
headers := make(map[string]interface{})
|
||||||
|
for k, v := range resp.Header {
|
||||||
|
if len(v) == 1 {
|
||||||
|
headers[k] = v[0]
|
||||||
|
} else {
|
||||||
|
headers[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp, respBody, headers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func signedSessionRetryAfterSeconds(resp *http.Response) int {
|
||||||
|
if resp == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
value := strings.TrimSpace(resp.Header.Get("Retry-After"))
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if seconds, err := strconv.Atoi(value); err == nil {
|
||||||
|
if seconds < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return seconds
|
||||||
|
}
|
||||||
|
if retryAt, err := http.ParseTime(value); err == nil {
|
||||||
|
seconds := int(time.Until(retryAt).Seconds())
|
||||||
|
if seconds < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return seconds
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func hmacSHA256Bytes(key, message []byte) []byte {
|
||||||
|
mac := hmac.New(sha256.New, key)
|
||||||
|
mac.Write(message)
|
||||||
|
return mac.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPendingSignedSessionGrant(extensionID, grant string) {
|
||||||
|
extensionID = strings.TrimSpace(extensionID)
|
||||||
|
grant = strings.TrimSpace(grant)
|
||||||
|
if extensionID == "" || grant == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingSignedSessionGrantsMu.Lock()
|
||||||
|
pendingSignedSessionGrants[extensionID] = grant
|
||||||
|
pendingSignedSessionGrantsMu.Unlock()
|
||||||
|
}
|
||||||
@@ -330,22 +330,26 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
|
func (s *extensionStore) findExtension(extensionID string) (*storeExtension, error) {
|
||||||
registry, err := s.fetchRegistry(false)
|
registry, err := s.fetchRegistry(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var ext *storeExtension
|
|
||||||
for _, e := range registry.Extensions {
|
for _, e := range registry.Extensions {
|
||||||
if e.ID == extensionID {
|
if e.ID == extensionID {
|
||||||
ext = &e
|
ext := e
|
||||||
break
|
return &ext, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ext == nil {
|
return nil, fmt.Errorf("extension %s not found in store", extensionID)
|
||||||
return fmt.Errorf("extension %s not found in store", extensionID)
|
}
|
||||||
|
|
||||||
|
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
|
||||||
|
ext, err := s.findExtension(extensionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
|
if err := requireHTTPSURL(ext.getDownloadURL(), "extension download"); err != nil {
|
||||||
|
|||||||
+15
-1
@@ -13,7 +13,7 @@ import (
|
|||||||
var (
|
var (
|
||||||
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||||
multiUnderscore = regexp.MustCompile(`_+`)
|
multiUnderscore = regexp.MustCompile(`_+`)
|
||||||
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc):([0-9]+)\}`)
|
formattedNumberPlaceholderExpr = regexp.MustCompile(`\{(track|disc|playlist_position|playlistPosition|position):([0-9]+)\}`)
|
||||||
dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`)
|
dateFormatPlaceholderExpr = regexp.MustCompile(`\{date:([^{}]+)\}`)
|
||||||
yearPattern = regexp.MustCompile(`\d{4}`)
|
yearPattern = regexp.MustCompile(`\d{4}`)
|
||||||
)
|
)
|
||||||
@@ -99,6 +99,11 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
|||||||
"{album}": getString(metadata, "album"),
|
"{album}": getString(metadata, "album"),
|
||||||
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
||||||
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
|
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
|
||||||
|
"{playlist_position}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||||
|
"{playlist position}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||||
|
"{playlistPosition}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||||
|
"{position}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||||
|
"{playlist_position_raw}": formatRawNumber(getPlaylistPosition(metadata)),
|
||||||
"{year}": yearValue,
|
"{year}": yearValue,
|
||||||
"{date}": dateValue,
|
"{date}": dateValue,
|
||||||
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
||||||
@@ -120,6 +125,9 @@ func replaceFormattedNumberPlaceholders(template string, metadata map[string]int
|
|||||||
}
|
}
|
||||||
|
|
||||||
number := getInt(metadata, parts[1])
|
number := getInt(metadata, parts[1])
|
||||||
|
if parts[1] == "playlist_position" || parts[1] == "playlistPosition" || parts[1] == "position" {
|
||||||
|
number = getPlaylistPosition(metadata)
|
||||||
|
}
|
||||||
width, err := strconv.Atoi(parts[2])
|
width, err := strconv.Atoi(parts[2])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -177,6 +185,8 @@ func getInt(m map[string]interface{}, key string) int {
|
|||||||
candidateKeys = append(candidateKeys, "track_number")
|
candidateKeys = append(candidateKeys, "track_number")
|
||||||
case "disc":
|
case "disc":
|
||||||
candidateKeys = append(candidateKeys, "disc_number")
|
candidateKeys = append(candidateKeys, "disc_number")
|
||||||
|
case "playlist_position", "playlistPosition", "playlist position", "position":
|
||||||
|
candidateKeys = append(candidateKeys, "playlistPosition", "playlist position", "position")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, candidate := range candidateKeys {
|
for _, candidate := range candidateKeys {
|
||||||
@@ -200,6 +210,10 @@ func getInt(m map[string]interface{}, key string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getPlaylistPosition(metadata map[string]interface{}) int {
|
||||||
|
return getInt(metadata, "playlist_position")
|
||||||
|
}
|
||||||
|
|
||||||
func formatTrackNumber(n int) string {
|
func formatTrackNumber(n int) string {
|
||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -55,6 +55,23 @@ func TestBuildFilenameFromTemplate_InlineNumberFormatting(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildFilenameFromTemplate_PlaylistPositionFormatting(t *testing.T) {
|
||||||
|
metadata := map[string]interface{}{
|
||||||
|
"playlist_position": 4,
|
||||||
|
"artist": "Artist Name",
|
||||||
|
"title": "Song Name",
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted := buildFilenameFromTemplate(
|
||||||
|
"{playlist_position:02} - {artist} - {title}",
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
expected := "04 - Artist Name - Song Name"
|
||||||
|
if formatted != expected {
|
||||||
|
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) {
|
func TestBuildFilenameFromTemplate_DateStrftimeFormatting(t *testing.T) {
|
||||||
metadata := map[string]interface{}{
|
metadata := map[string]interface{}{
|
||||||
"artist": "Artist Name",
|
"artist": "Artist Name",
|
||||||
|
|||||||
+13
-13
@@ -5,25 +5,25 @@ go 1.25.0
|
|||||||
toolchain go1.25.9
|
toolchain go1.25.9
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2
|
github.com/go-flac/flacpicture/v2 v2.0.2
|
||||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||||
github.com/go-flac/go-flac/v2 v2.0.4
|
github.com/go-flac/go-flac/v2 v2.0.4
|
||||||
github.com/refraction-networking/utls v1.8.2
|
github.com/refraction-networking/utls v1.8.2
|
||||||
golang.org/x/crypto v0.50.0
|
golang.org/x/crypto v0.53.0
|
||||||
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b
|
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2
|
||||||
golang.org/x/net v0.53.0
|
golang.org/x/net v0.56.0
|
||||||
golang.org/x/text v0.36.0
|
golang.org/x/text v0.38.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dlclark/regexp2/v2 v2.2.2 // indirect
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
|
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect
|
||||||
github.com/klauspost/compress v1.18.5 // indirect
|
github.com/klauspost/compress v1.18.6 // indirect
|
||||||
golang.org/x/mod v0.35.0 // indirect
|
golang.org/x/mod v0.37.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
golang.org/x/tools v0.44.0 // indirect
|
golang.org/x/tools v0.47.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
+30
-30
@@ -1,13 +1,13 @@
|
|||||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
|
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 h1:DjKLmvKK9u15djHZ88N8M0DhgnHVgJJ8bnEe0h7Lga8=
|
||||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||||
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
||||||
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||||
@@ -16,12 +16,14 @@ github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sY
|
|||||||
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
|
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||||
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
|
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE=
|
||||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||||
@@ -30,23 +32,21 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
|||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/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 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b h1:Qt2eaXcZ8x20iAcoZ6AceeMMtnjuPHvC51KRCH1DKSQ=
|
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2 h1:zoM1gIKhVkcQNm43kad8OHLgPNoJ12xIqmxHtKr8Mug=
|
||||||
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b/go.mod h1:5Fu78lew5ucMXt8w2KYcwvxu2rkC/liHzUvaoiI+H/M=
|
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2/go.mod h1:QGMqsqLn6orFQ/ksqYMf+Fa33Soa1vPoHEd0Pj7N+lQ=
|
||||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
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=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
+121
-84
@@ -1,7 +1,9 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -77,6 +79,7 @@ var sharedTransport = &http.Transport{
|
|||||||
WriteBufferSize: 64 * 1024,
|
WriteBufferSize: 64 * 1024,
|
||||||
ReadBufferSize: 64 * 1024,
|
ReadBufferSize: 64 * 1024,
|
||||||
DisableCompression: true,
|
DisableCompression: true,
|
||||||
|
TLSClientConfig: newTLSCompatibilityConfig(false),
|
||||||
}
|
}
|
||||||
|
|
||||||
var extensionAPITransport = &http.Transport{
|
var extensionAPITransport = &http.Transport{
|
||||||
@@ -95,6 +98,7 @@ var extensionAPITransport = &http.Transport{
|
|||||||
WriteBufferSize: 64 * 1024,
|
WriteBufferSize: 64 * 1024,
|
||||||
ReadBufferSize: 64 * 1024,
|
ReadBufferSize: 64 * 1024,
|
||||||
DisableCompression: false,
|
DisableCompression: false,
|
||||||
|
TLSClientConfig: newTLSCompatibilityConfig(false),
|
||||||
}
|
}
|
||||||
|
|
||||||
var metadataTransport = &http.Transport{
|
var metadataTransport = &http.Transport{
|
||||||
@@ -113,6 +117,7 @@ var metadataTransport = &http.Transport{
|
|||||||
WriteBufferSize: 32 * 1024,
|
WriteBufferSize: 32 * 1024,
|
||||||
ReadBufferSize: 32 * 1024,
|
ReadBufferSize: 32 * 1024,
|
||||||
DisableCompression: true,
|
DisableCompression: true,
|
||||||
|
TLSClientConfig: newTLSCompatibilityConfig(false),
|
||||||
}
|
}
|
||||||
|
|
||||||
var sharedClient = &http.Client{
|
var sharedClient = &http.Client{
|
||||||
@@ -176,17 +181,7 @@ func GetNetworkCompatibilityOptions() NetworkCompatibilityOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyTLSCompatibility(transport *http.Transport, insecureTLS bool) {
|
func applyTLSCompatibility(transport *http.Transport, insecureTLS bool) {
|
||||||
if insecureTLS {
|
transport.TLSClientConfig = newTLSCompatibilityConfig(insecureTLS)
|
||||||
cfg := &tls.Config{InsecureSkipVerify: true}
|
|
||||||
if transport.TLSClientConfig != nil {
|
|
||||||
cfg = transport.TLSClientConfig.Clone()
|
|
||||||
cfg.InsecureSkipVerify = true
|
|
||||||
}
|
|
||||||
transport.TLSClientConfig = cfg
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
transport.TLSClientConfig = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type compatibilityTransport struct {
|
type compatibilityTransport struct {
|
||||||
@@ -444,101 +439,143 @@ func (e *ISPBlockingError) Error() string {
|
|||||||
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
// isTransientNetworkError reports retryable transport failures such as
|
||||||
|
// timeouts and temporary DNS errors. Permanent DNS misses are excluded.
|
||||||
|
func isTransientNetworkError(err error) bool {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return false
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var netErr net.Error
|
||||||
|
return errors.As(err, &netErr) && (netErr.Timeout() || netErr.Temporary())
|
||||||
|
}
|
||||||
|
|
||||||
|
// isConnectivityFailure reports DNS, dial, timeout, TLS, or truncated transport
|
||||||
|
// errors. Application-level API messages are excluded.
|
||||||
|
func isConnectivityFailure(err error) bool {
|
||||||
|
return connectivityFailureReason(err) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectivityFailureReason(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return "Request timed out - ISP may be throttling"
|
||||||
|
}
|
||||||
|
if errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
|
return "Connection closed unexpectedly - ISP may be blocking"
|
||||||
}
|
}
|
||||||
|
|
||||||
domain := extractDomain(requestURL)
|
var urlErr *url.Error
|
||||||
errStr := strings.ToLower(err.Error())
|
if errors.As(err, &urlErr) {
|
||||||
|
if urlErr.Timeout() {
|
||||||
|
return "Connection timed out - ISP may be blocking access"
|
||||||
|
}
|
||||||
|
if urlErr.Err != nil {
|
||||||
|
if reason := connectivityFailureReason(urlErr.Err); reason != "" {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var dnsErr *net.DNSError
|
var dnsErr *net.DNSError
|
||||||
if errors.As(err, &dnsErr) {
|
if errors.As(err, &dnsErr) {
|
||||||
if dnsErr.IsNotFound || dnsErr.IsTemporary {
|
if dnsErr.IsNotFound || dnsErr.IsTimeout || dnsErr.IsTemporary {
|
||||||
return &ISPBlockingError{
|
return "DNS resolution failed - domain may be blocked by ISP"
|
||||||
Domain: domain,
|
|
||||||
Reason: "DNS resolution failed - domain may be blocked by ISP",
|
|
||||||
OriginalErr: err,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var opErr *net.OpError
|
var opErr *net.OpError
|
||||||
if errors.As(err, &opErr) {
|
if errors.As(err, &opErr) {
|
||||||
if opErr.Op == "dial" {
|
if opErr.Timeout() {
|
||||||
var syscallErr syscall.Errno
|
return "Connection timed out - ISP may be blocking access"
|
||||||
if errors.As(opErr.Err, &syscallErr) {
|
}
|
||||||
switch syscallErr {
|
var errno syscall.Errno
|
||||||
case syscall.ECONNREFUSED:
|
if errors.As(opErr.Err, &errno) {
|
||||||
return &ISPBlockingError{
|
switch errno {
|
||||||
Domain: domain,
|
case syscall.ECONNREFUSED:
|
||||||
Reason: "Connection refused - port may be blocked by ISP/firewall",
|
return "Connection refused - port may be blocked by ISP/firewall"
|
||||||
OriginalErr: err,
|
case syscall.ECONNRESET:
|
||||||
}
|
return "Connection reset - ISP may be intercepting traffic"
|
||||||
case syscall.ECONNRESET:
|
case syscall.ETIMEDOUT:
|
||||||
return &ISPBlockingError{
|
return "Connection timed out - ISP may be blocking access"
|
||||||
Domain: domain,
|
case syscall.ENETUNREACH:
|
||||||
Reason: "Connection reset - ISP may be intercepting traffic",
|
return "Network unreachable - ISP may be blocking route"
|
||||||
OriginalErr: err,
|
case syscall.EHOSTUNREACH:
|
||||||
}
|
return "Host unreachable - ISP may be blocking destination"
|
||||||
case syscall.ETIMEDOUT:
|
|
||||||
return &ISPBlockingError{
|
|
||||||
Domain: domain,
|
|
||||||
Reason: "Connection timed out - ISP may be blocking access",
|
|
||||||
OriginalErr: err,
|
|
||||||
}
|
|
||||||
case syscall.ENETUNREACH:
|
|
||||||
return &ISPBlockingError{
|
|
||||||
Domain: domain,
|
|
||||||
Reason: "Network unreachable - ISP may be blocking route",
|
|
||||||
OriginalErr: err,
|
|
||||||
}
|
|
||||||
case syscall.EHOSTUNREACH:
|
|
||||||
return &ISPBlockingError{
|
|
||||||
Domain: domain,
|
|
||||||
Reason: "Host unreachable - ISP may be blocking destination",
|
|
||||||
OriginalErr: err,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var tlsErr *tls.RecordHeaderError
|
var tlsErr *tls.RecordHeaderError
|
||||||
if errors.As(err, &tlsErr) {
|
if errors.As(err, &tlsErr) {
|
||||||
return &ISPBlockingError{
|
return "TLS handshake failed - ISP may be intercepting HTTPS traffic"
|
||||||
Domain: domain,
|
}
|
||||||
Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic",
|
|
||||||
OriginalErr: err,
|
var certErr x509.CertificateInvalidError
|
||||||
|
if errors.As(err, &certErr) {
|
||||||
|
return "Certificate error - ISP may be using MITM proxy"
|
||||||
|
}
|
||||||
|
var hostnameErr x509.HostnameError
|
||||||
|
if errors.As(err, &hostnameErr) {
|
||||||
|
return "Certificate error - ISP may be using MITM proxy"
|
||||||
|
}
|
||||||
|
var unknownAuth x509.UnknownAuthorityError
|
||||||
|
if errors.As(err, &unknownAuth) {
|
||||||
|
return "Certificate error - ISP may be using MITM proxy"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTLSHandshakeOrResetError reports TLS handshake/cert failures and TCP resets
|
||||||
|
// that should trigger a Chrome fingerprint retry.
|
||||||
|
func isTLSHandshakeOrResetError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var recordErr *tls.RecordHeaderError
|
||||||
|
if errors.As(err, &recordErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var certErr x509.CertificateInvalidError
|
||||||
|
if errors.As(err, &certErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var hostnameErr x509.HostnameError
|
||||||
|
if errors.As(err, &hostnameErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var unknownAuth x509.UnknownAuthorityError
|
||||||
|
if errors.As(err, &unknownAuth) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var opErr *net.OpError
|
||||||
|
if errors.As(err, &opErr) {
|
||||||
|
var errno syscall.Errno
|
||||||
|
if errors.As(opErr.Err, &errno) && errno == syscall.ECONNRESET {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
blockingPatterns := []struct {
|
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||||
pattern string
|
if err == nil {
|
||||||
reason string
|
return nil
|
||||||
}{
|
|
||||||
{"connection reset by peer", "Connection reset - ISP may be intercepting traffic"},
|
|
||||||
{"connection refused", "Connection refused - port may be blocked"},
|
|
||||||
{"no such host", "DNS lookup failed - domain may be blocked by ISP"},
|
|
||||||
{"i/o timeout", "Connection timed out - ISP may be blocking access"},
|
|
||||||
{"network is unreachable", "Network unreachable - ISP may be blocking route"},
|
|
||||||
{"tls: ", "TLS error - ISP may be intercepting HTTPS traffic"},
|
|
||||||
{"certificate", "Certificate error - ISP may be using MITM proxy"},
|
|
||||||
{"eof", "Connection closed unexpectedly - ISP may be blocking"},
|
|
||||||
{"context deadline exceeded", "Request timed out - ISP may be throttling"},
|
|
||||||
}
|
}
|
||||||
|
reason := connectivityFailureReason(err)
|
||||||
for _, bp := range blockingPatterns {
|
if reason == "" {
|
||||||
if strings.Contains(errStr, bp.pattern) {
|
return nil
|
||||||
return &ISPBlockingError{
|
}
|
||||||
Domain: domain,
|
return &ISPBlockingError{
|
||||||
Reason: bp.reason,
|
Domain: extractDomain(requestURL),
|
||||||
OriginalErr: err,
|
Reason: reason,
|
||||||
}
|
OriginalErr: err,
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"context"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -25,11 +29,34 @@ func TestHTTPUtilityHelpers(t *testing.T) {
|
|||||||
if GetSharedClient() == nil || GetDownloadClient() == nil {
|
if GetSharedClient() == nil || GetDownloadClient() == nil {
|
||||||
t.Fatal("expected shared clients")
|
t.Fatal("expected shared clients")
|
||||||
}
|
}
|
||||||
|
if sharedTransport.TLSClientConfig == nil || sharedTransport.TLSClientConfig.RootCAs == nil {
|
||||||
|
t.Fatal("expected supplemental TLS root pool")
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode([]byte(isrgRootX2PEM))
|
||||||
|
if block == nil {
|
||||||
|
t.Fatal("failed to decode ISRG Root X2")
|
||||||
|
}
|
||||||
|
rootX2, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse ISRG Root X2: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := rootX2.Verify(x509.VerifyOptions{
|
||||||
|
Roots: supplementalRootCAs(),
|
||||||
|
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("ISRG Root X2 should verify with supplemental roots: %v", err)
|
||||||
|
}
|
||||||
SetNetworkCompatibilityOptions(true, true)
|
SetNetworkCompatibilityOptions(true, true)
|
||||||
if opts := GetNetworkCompatibilityOptions(); !opts.AllowHTTP || !opts.InsecureTLS {
|
if opts := GetNetworkCompatibilityOptions(); !opts.AllowHTTP || !opts.InsecureTLS {
|
||||||
t.Fatalf("network opts = %#v", opts)
|
t.Fatalf("network opts = %#v", opts)
|
||||||
}
|
}
|
||||||
|
if !sharedTransport.TLSClientConfig.InsecureSkipVerify {
|
||||||
|
t.Fatal("expected insecure TLS config to be applied")
|
||||||
|
}
|
||||||
SetNetworkCompatibilityOptions(false, false)
|
SetNetworkCompatibilityOptions(false, false)
|
||||||
|
if sharedTransport.TLSClientConfig == nil || sharedTransport.TLSClientConfig.InsecureSkipVerify {
|
||||||
|
t.Fatal("expected secure TLS config to be restored")
|
||||||
|
}
|
||||||
if !canFallbackToHTTP(&http.Request{Method: http.MethodGet}) {
|
if !canFallbackToHTTP(&http.Request{Method: http.MethodGet}) {
|
||||||
t.Fatal("GET should fallback")
|
t.Fatal("GET should fallback")
|
||||||
}
|
}
|
||||||
@@ -106,15 +133,24 @@ func TestHTTPUtilityHelpers(t *testing.T) {
|
|||||||
if getRetryAfterDuration(&http.Response{Header: http.Header{"Retry-After": []string{"bad"}}}) != 0 {
|
if getRetryAfterDuration(&http.Response{Header: http.Header{"Retry-After": []string{"bad"}}}) != 0 {
|
||||||
t.Fatal("invalid retry-after should be zero")
|
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") {
|
resetErr := &net.OpError{Op: "read", Err: syscall.ECONNRESET}
|
||||||
|
if isp := IsISPBlocking(resetErr, "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") {
|
||||||
t.Fatalf("IsISPBlocking = %#v", isp)
|
t.Fatalf("IsISPBlocking = %#v", isp)
|
||||||
}
|
}
|
||||||
if !CheckAndLogISPBlocking(errors.New("i/o timeout"), "https://timeout.example/x", "test") {
|
timeoutErr := &net.OpError{Op: "dial", Err: syscall.ETIMEDOUT}
|
||||||
|
if !CheckAndLogISPBlocking(timeoutErr, "https://timeout.example/x", "test") {
|
||||||
t.Fatal("expected logged ISP blocking")
|
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") {
|
refusedErr := &net.OpError{Op: "dial", Err: syscall.ECONNREFUSED}
|
||||||
|
if wrapped := WrapErrorWithISPCheck(refusedErr, "https://refused.example/x", "test"); wrapped == nil || !strings.Contains(wrapped.Error(), "ISP blocking") {
|
||||||
t.Fatalf("WrapErrorWithISPCheck = %v", wrapped)
|
t.Fatalf("WrapErrorWithISPCheck = %v", wrapped)
|
||||||
}
|
}
|
||||||
|
if !isTransientNetworkError(context.DeadlineExceeded) || isTransientNetworkError(&net.DNSError{IsNotFound: true}) {
|
||||||
|
t.Fatal("isTransientNetworkError mismatch")
|
||||||
|
}
|
||||||
|
if !isConnectivityFailure(&net.DNSError{IsNotFound: true}) || !isConnectivityFailure(context.DeadlineExceeded) {
|
||||||
|
t.Fatal("isConnectivityFailure mismatch")
|
||||||
|
}
|
||||||
if WrapErrorWithISPCheck(nil, "", "test") != nil {
|
if WrapErrorWithISPCheck(nil, "", "test") != nil {
|
||||||
t.Fatal("nil wrap should stay nil")
|
t.Fatal("nil wrap should stay nil")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,9 +42,12 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
opts := GetNetworkCompatibilityOptions()
|
||||||
tlsConn := utls.UClient(conn, &utls.Config{
|
tlsConn := utls.UClient(conn, &utls.Config{
|
||||||
ServerName: host,
|
RootCAs: supplementalRootCAs(),
|
||||||
NextProtos: []string{"h2", "http/1.1"},
|
InsecureSkipVerify: opts.InsecureTLS,
|
||||||
|
ServerName: host,
|
||||||
|
NextProtos: []string{"h2", "http/1.1"},
|
||||||
}, utls.HelloChrome_Auto)
|
}, utls.HelloChrome_Auto)
|
||||||
|
|
||||||
if err := tlsConn.Handshake(); err != nil {
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
@@ -141,13 +144,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
errStr := strings.ToLower(err.Error())
|
if isTLSHandshakeOrResetError(err) {
|
||||||
tlsRelated := strings.Contains(errStr, "tls") ||
|
|
||||||
strings.Contains(errStr, "handshake") ||
|
|
||||||
strings.Contains(errStr, "certificate") ||
|
|
||||||
strings.Contains(errStr, "connection reset")
|
|
||||||
|
|
||||||
if tlsRelated {
|
|
||||||
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
||||||
|
|
||||||
reqCopy := req.Clone(req.Context())
|
reqCopy := req.Clone(req.Context())
|
||||||
|
|||||||
+204
-29
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -76,6 +77,9 @@ var supportedAudioFormats = map[string]bool{
|
|||||||
".ape": true,
|
".ape": true,
|
||||||
".wv": true,
|
".wv": true,
|
||||||
".mpc": true,
|
".mpc": true,
|
||||||
|
".wav": true,
|
||||||
|
".aiff": true,
|
||||||
|
".aif": true,
|
||||||
".cue": true,
|
".cue": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +93,18 @@ type scannedCueFileInfo struct {
|
|||||||
audioPath string
|
audioPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type libraryScanTask struct {
|
||||||
|
index int
|
||||||
|
info libraryAudioFileInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
type libraryScanTaskResult struct {
|
||||||
|
index int
|
||||||
|
path string
|
||||||
|
results []LibraryScanResult
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
func isLibraryStagingFile(path string) bool {
|
func isLibraryStagingFile(path string) bool {
|
||||||
name := strings.ToLower(filepath.Base(path))
|
name := strings.ToLower(filepath.Base(path))
|
||||||
if strings.HasSuffix(name, ".partial") {
|
if strings.HasSuffix(name, ".partial") {
|
||||||
@@ -147,6 +163,129 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
|
|||||||
return files, nil
|
return files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func libraryScanWorkerCount(taskCount int) int {
|
||||||
|
if taskCount < 16 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
workers := runtime.NumCPU()
|
||||||
|
if workers > 4 {
|
||||||
|
workers = 4
|
||||||
|
}
|
||||||
|
if workers < 2 {
|
||||||
|
workers = 2
|
||||||
|
}
|
||||||
|
if workers > taskCount {
|
||||||
|
workers = taskCount
|
||||||
|
}
|
||||||
|
return workers
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateLibraryScanProgress(scannedFiles, totalFiles int, currentPath string) {
|
||||||
|
libraryScanProgressMu.Lock()
|
||||||
|
libraryScanProgress.ScannedFiles = scannedFiles
|
||||||
|
libraryScanProgress.CurrentFile = filepath.Base(currentPath)
|
||||||
|
if totalFiles > 0 {
|
||||||
|
libraryScanProgress.ProgressPct = float64(scannedFiles) / float64(totalFiles) * 100
|
||||||
|
}
|
||||||
|
libraryScanProgressMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanLibraryAudioTasksParallel(tasks []libraryScanTask, scanTime string, cancelCh <-chan struct{}, totalFiles int, completed *int) (map[int][]LibraryScanResult, int, error) {
|
||||||
|
resultsByIndex := make(map[int][]LibraryScanResult, len(tasks))
|
||||||
|
if len(tasks) == 0 {
|
||||||
|
return resultsByIndex, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
workers := libraryScanWorkerCount(len(tasks))
|
||||||
|
if workers <= 1 {
|
||||||
|
errorCount := 0
|
||||||
|
for _, task := range tasks {
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return resultsByIndex, errorCount, fmt.Errorf("scan cancelled")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
result, err := scanAudioFileWithKnownModTime(task.info.path, scanTime, task.info.modTime)
|
||||||
|
*completed++
|
||||||
|
updateLibraryScanProgress(*completed, totalFiles, task.info.path)
|
||||||
|
if err != nil {
|
||||||
|
errorCount++
|
||||||
|
GoLog("[LibraryScan] Error scanning %s: %v\n", task.info.path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resultsByIndex[task.index] = []LibraryScanResult{*result}
|
||||||
|
}
|
||||||
|
return resultsByIndex, errorCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
taskCh := make(chan libraryScanTask)
|
||||||
|
resultCh := make(chan libraryScanTaskResult, workers)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for task := range taskCh {
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
result, err := scanAudioFileWithKnownModTime(task.info.path, scanTime, task.info.modTime)
|
||||||
|
taskResult := libraryScanTaskResult{
|
||||||
|
index: task.index,
|
||||||
|
path: task.info.path,
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
if err == nil && result != nil {
|
||||||
|
taskResult.results = []LibraryScanResult{*result}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return
|
||||||
|
case resultCh <- taskResult:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(taskCh)
|
||||||
|
for _, task := range tasks {
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return
|
||||||
|
case taskCh <- task:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(resultCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
errorCount := 0
|
||||||
|
for taskResult := range resultCh {
|
||||||
|
*completed++
|
||||||
|
updateLibraryScanProgress(*completed, totalFiles, taskResult.path)
|
||||||
|
if taskResult.err != nil {
|
||||||
|
errorCount++
|
||||||
|
GoLog("[LibraryScan] Error scanning %s: %v\n", taskResult.path, taskResult.err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resultsByIndex[taskResult.index] = taskResult.results
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-cancelCh:
|
||||||
|
return resultsByIndex, errorCount, fmt.Errorf("scan cancelled")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return resultsByIndex, errorCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
func SetLibraryCoverCacheDir(cacheDir string) {
|
func SetLibraryCoverCacheDir(cacheDir string) {
|
||||||
libraryCoverCacheMu.Lock()
|
libraryCoverCacheMu.Lock()
|
||||||
libraryCoverCacheDir = cacheDir
|
libraryCoverCacheDir = cacheDir
|
||||||
@@ -222,6 +361,10 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resultsByIndex := make(map[int][]LibraryScanResult, totalFiles)
|
||||||
|
audioTasks := make([]libraryScanTask, 0, totalFiles)
|
||||||
|
completedFiles := 0
|
||||||
|
|
||||||
for i, fileInfo := range audioFileInfos {
|
for i, fileInfo := range audioFileInfos {
|
||||||
filePath := fileInfo.path
|
filePath := fileInfo.path
|
||||||
select {
|
select {
|
||||||
@@ -230,12 +373,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryScanProgressMu.Lock()
|
|
||||||
libraryScanProgress.ScannedFiles = i + 1
|
|
||||||
libraryScanProgress.CurrentFile = filepath.Base(filePath)
|
|
||||||
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
|
|
||||||
libraryScanProgressMu.Unlock()
|
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
|
||||||
if ext == ".cue" {
|
if ext == ".cue" {
|
||||||
@@ -257,26 +394,44 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
errorCount++
|
errorCount++
|
||||||
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
|
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
|
||||||
|
completedFiles++
|
||||||
|
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
results = append(results, cueResults...)
|
resultsByIndex[i] = cueResults
|
||||||
|
completedFiles++
|
||||||
|
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
|
||||||
GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults))
|
GoLog("[LibraryScan] CUE sheet %s: %d tracks\n", filepath.Base(filePath), len(cueResults))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if cueReferencedAudioFiles[filePath] {
|
if cueReferencedAudioFiles[filePath] {
|
||||||
|
completedFiles++
|
||||||
|
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
|
||||||
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
|
audioTasks = append(audioTasks, libraryScanTask{index: i, info: fileInfo})
|
||||||
if err != nil {
|
}
|
||||||
errorCount++
|
|
||||||
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
results = append(results, *result)
|
audioResults, audioErrors, err := scanLibraryAudioTasksParallel(
|
||||||
|
audioTasks,
|
||||||
|
scanTime,
|
||||||
|
cancelCh,
|
||||||
|
totalFiles,
|
||||||
|
&completedFiles,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "[]", err
|
||||||
|
}
|
||||||
|
errorCount += audioErrors
|
||||||
|
for index, scanResults := range audioResults {
|
||||||
|
resultsByIndex[index] = scanResults
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range audioFileInfos {
|
||||||
|
results = append(results, resultsByIndex[i]...)
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryScanProgressMu.Lock()
|
libraryScanProgressMu.Lock()
|
||||||
@@ -340,6 +495,10 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
|
|||||||
return scanOggFile(filePath, result, displayNameHint)
|
return scanOggFile(filePath, result, displayNameHint)
|
||||||
case ".ape", ".wv", ".mpc":
|
case ".ape", ".wv", ".mpc":
|
||||||
return scanAPEFile(filePath, result, displayNameHint)
|
return scanAPEFile(filePath, result, displayNameHint)
|
||||||
|
case ".wav":
|
||||||
|
return scanWAVFile(filePath, result, displayNameHint)
|
||||||
|
case ".aiff", ".aif", ".aifc":
|
||||||
|
return scanAIFFFile(filePath, result, displayNameHint)
|
||||||
default:
|
default:
|
||||||
return scanFromFilename(filePath, displayNameHint, result)
|
return scanFromFilename(filePath, displayNameHint, result)
|
||||||
}
|
}
|
||||||
@@ -479,7 +638,7 @@ func libraryFormatForM4ACodec(codec string) string {
|
|||||||
|
|
||||||
func isLosslessLibraryFormat(format string) bool {
|
func isLosslessLibraryFormat(format string) bool {
|
||||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||||
case "flac", "alac":
|
case "flac", "alac", "wav", "aiff", "aif", "aifc":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -867,6 +1026,10 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resultsByIndex := make(map[int][]LibraryScanResult, len(filesToScan))
|
||||||
|
audioTasks := make([]libraryScanTask, 0, len(filesToScan))
|
||||||
|
completedFiles := skippedCount
|
||||||
|
|
||||||
for i, f := range filesToScan {
|
for i, f := range filesToScan {
|
||||||
select {
|
select {
|
||||||
case <-cancelCh:
|
case <-cancelCh:
|
||||||
@@ -874,12 +1037,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryScanProgressMu.Lock()
|
|
||||||
libraryScanProgress.ScannedFiles = skippedCount + i + 1
|
|
||||||
libraryScanProgress.CurrentFile = filepath.Base(f.path)
|
|
||||||
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
|
|
||||||
libraryScanProgressMu.Unlock()
|
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(f.path))
|
ext := strings.ToLower(filepath.Ext(f.path))
|
||||||
|
|
||||||
if ext == ".cue" {
|
if ext == ".cue" {
|
||||||
@@ -901,24 +1058,42 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
errorCount++
|
errorCount++
|
||||||
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
|
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
|
||||||
|
completedFiles++
|
||||||
|
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
results = append(results, cueResults...)
|
resultsByIndex[i] = cueResults
|
||||||
|
completedFiles++
|
||||||
|
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if cueReferencedAudioFilesInc[f.path] {
|
if cueReferencedAudioFilesInc[f.path] {
|
||||||
|
completedFiles++
|
||||||
|
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
|
audioTasks = append(audioTasks, libraryScanTask{index: i, info: f})
|
||||||
if err != nil {
|
}
|
||||||
errorCount++
|
|
||||||
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
results = append(results, *result)
|
audioResults, audioErrors, err := scanLibraryAudioTasksParallel(
|
||||||
|
audioTasks,
|
||||||
|
scanTime,
|
||||||
|
cancelCh,
|
||||||
|
totalFiles,
|
||||||
|
&completedFiles,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "{}", err
|
||||||
|
}
|
||||||
|
errorCount += audioErrors
|
||||||
|
for index, scanResults := range audioResults {
|
||||||
|
resultsByIndex[index] = scanResults
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range filesToScan {
|
||||||
|
results = append(results, resultsByIndex[i]...)
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryScanProgressMu.Lock()
|
libraryScanProgressMu.Lock()
|
||||||
|
|||||||
+555
-119
@@ -20,6 +20,12 @@ const (
|
|||||||
durationToleranceSec = 10.0
|
durationToleranceSec = 10.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
lyricsProviderUnavailableCooldown = 10 * time.Minute
|
||||||
|
lyricsProviderParallelism = 3
|
||||||
|
lyricsProviderPriorityGrace = 5000 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
LyricsProviderLRCLIB = "lrclib"
|
LyricsProviderLRCLIB = "lrclib"
|
||||||
LyricsProviderNetease = "netease"
|
LyricsProviderNetease = "netease"
|
||||||
@@ -31,6 +37,7 @@ const (
|
|||||||
LyricsProviderYouTube = "youtube"
|
LyricsProviderYouTube = "youtube"
|
||||||
LyricsProviderKugou = "kugou"
|
LyricsProviderKugou = "kugou"
|
||||||
LyricsProviderGenius = "genius"
|
LyricsProviderGenius = "genius"
|
||||||
|
LyricsProviderLyricsPlus = "lyricsplus"
|
||||||
)
|
)
|
||||||
|
|
||||||
var DefaultLyricsProviders = []string{
|
var DefaultLyricsProviders = []string{
|
||||||
@@ -45,6 +52,33 @@ var (
|
|||||||
appVersion string
|
appVersion string
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type lyricsProviderHealthEntry struct {
|
||||||
|
unavailableUntil time.Time
|
||||||
|
reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
type lyricsProviderSearchRequest struct {
|
||||||
|
spotifyID string
|
||||||
|
trackName string
|
||||||
|
artistName string
|
||||||
|
primaryArtist string
|
||||||
|
simplifiedTrack string
|
||||||
|
durationSec float64
|
||||||
|
fetchOptions LyricsFetchOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
type lyricsProviderSearchResult struct {
|
||||||
|
index int
|
||||||
|
providerName string
|
||||||
|
lyrics *LyricsResponse
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
lyricsProviderHealthMu sync.RWMutex
|
||||||
|
lyricsProviderHealth = make(map[string]lyricsProviderHealthEntry)
|
||||||
|
)
|
||||||
|
|
||||||
func SetAppVersion(version string) {
|
func SetAppVersion(version string) {
|
||||||
normalized := strings.TrimSpace(version)
|
normalized := strings.TrimSpace(version)
|
||||||
|
|
||||||
@@ -98,6 +132,7 @@ func SetLyricsProviderOrder(providers []string) {
|
|||||||
|
|
||||||
if len(providers) == 0 {
|
if len(providers) == 0 {
|
||||||
lyricsProviders = nil
|
lyricsProviders = nil
|
||||||
|
clearLyricsProviderHealth()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +147,7 @@ func SetLyricsProviderOrder(providers []string) {
|
|||||||
LyricsProviderYouTube: true,
|
LyricsProviderYouTube: true,
|
||||||
LyricsProviderKugou: true,
|
LyricsProviderKugou: true,
|
||||||
LyricsProviderGenius: true,
|
LyricsProviderGenius: true,
|
||||||
|
LyricsProviderLyricsPlus: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
var valid []string
|
var valid []string
|
||||||
@@ -123,9 +159,131 @@ func SetLyricsProviderOrder(providers []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lyricsProviders = valid
|
lyricsProviders = valid
|
||||||
|
clearLyricsProviderHealth()
|
||||||
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
GoLog("[Lyrics] Provider order set to: %v\n", valid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clearLyricsProviderHealth() {
|
||||||
|
lyricsProviderHealthMu.Lock()
|
||||||
|
defer lyricsProviderHealthMu.Unlock()
|
||||||
|
lyricsProviderHealth = make(map[string]lyricsProviderHealthEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lyricsProviderHealthKey(providerName string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(providerName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSkipLyricsProvider(providerName string) (bool, time.Duration, string) {
|
||||||
|
key := lyricsProviderHealthKey(providerName)
|
||||||
|
if key == "" {
|
||||||
|
return false, 0, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
lyricsProviderHealthMu.RLock()
|
||||||
|
entry, ok := lyricsProviderHealth[key]
|
||||||
|
lyricsProviderHealthMu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return false, 0, ""
|
||||||
|
}
|
||||||
|
if !now.Before(entry.unavailableUntil) {
|
||||||
|
lyricsProviderHealthMu.Lock()
|
||||||
|
if current, exists := lyricsProviderHealth[key]; exists && !now.Before(current.unavailableUntil) {
|
||||||
|
delete(lyricsProviderHealth, key)
|
||||||
|
}
|
||||||
|
lyricsProviderHealthMu.Unlock()
|
||||||
|
return false, 0, ""
|
||||||
|
}
|
||||||
|
return true, time.Until(entry.unavailableUntil), entry.reason
|
||||||
|
}
|
||||||
|
|
||||||
|
func markLyricsProviderAvailable(providerName string) {
|
||||||
|
key := lyricsProviderHealthKey(providerName)
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lyricsProviderHealthMu.Lock()
|
||||||
|
delete(lyricsProviderHealth, key)
|
||||||
|
lyricsProviderHealthMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func markLyricsProviderUnavailable(providerName string, err error) {
|
||||||
|
if err == nil || !isLyricsProviderUnavailableError(err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := lyricsProviderHealthKey(providerName)
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reason := strings.TrimSpace(err.Error())
|
||||||
|
if len(reason) > 160 {
|
||||||
|
reason = reason[:160]
|
||||||
|
}
|
||||||
|
unavailableUntil := time.Now().Add(lyricsProviderUnavailableCooldown)
|
||||||
|
|
||||||
|
lyricsProviderHealthMu.Lock()
|
||||||
|
lyricsProviderHealth[key] = lyricsProviderHealthEntry{
|
||||||
|
unavailableUntil: unavailableUntil,
|
||||||
|
reason: reason,
|
||||||
|
}
|
||||||
|
lyricsProviderHealthMu.Unlock()
|
||||||
|
GoLog("[Lyrics] Provider %s marked unavailable for %s: %s\n", providerName, lyricsProviderUnavailableCooldown, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lyricsNotFoundSignals = []string{
|
||||||
|
"lyrics not found",
|
||||||
|
"no lyrics found",
|
||||||
|
"no songs found",
|
||||||
|
"not found on",
|
||||||
|
"empty track",
|
||||||
|
"empty search query",
|
||||||
|
"needs a deezer id",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider/API-level failures that should temporarily disable a lyrics source.
|
||||||
|
// Transport failures are handled by isConnectivityFailure via typed errors.
|
||||||
|
var lyricsServiceUnavailableSignals = []string{
|
||||||
|
"fetch failed",
|
||||||
|
"missing required parameters",
|
||||||
|
"request failed",
|
||||||
|
"request unsuccessful",
|
||||||
|
"search failed",
|
||||||
|
"search unavailable",
|
||||||
|
"rate limit",
|
||||||
|
"too many requests",
|
||||||
|
"operation too frequent",
|
||||||
|
"操作频繁",
|
||||||
|
"proxy returned http 429",
|
||||||
|
"proxy returned http 5",
|
||||||
|
"unexpected status code: 429",
|
||||||
|
"unexpected status code: 5",
|
||||||
|
"unexpected response code",
|
||||||
|
"returned http 429",
|
||||||
|
"returned http 5",
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLyricsProviderUnavailableError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
for _, signal := range lyricsNotFoundSignals {
|
||||||
|
if strings.Contains(msg, signal) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isConnectivityFailure(err) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, signal := range lyricsServiceUnavailableSignals {
|
||||||
|
if strings.Contains(msg, signal) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func GetLyricsProviderOrder() []string {
|
func GetLyricsProviderOrder() []string {
|
||||||
lyricsProvidersMu.RLock()
|
lyricsProvidersMu.RLock()
|
||||||
defer lyricsProvidersMu.RUnlock()
|
defer lyricsProvidersMu.RUnlock()
|
||||||
@@ -151,6 +309,7 @@ func GetAvailableLyricsProviders() []map[string]interface{} {
|
|||||||
{"id": LyricsProviderYouTube, "name": "YouTube", "has_proxy_dependency": true, "description": "YouTube lyrics"},
|
{"id": LyricsProviderYouTube, "name": "YouTube", "has_proxy_dependency": true, "description": "YouTube lyrics"},
|
||||||
{"id": LyricsProviderKugou, "name": "Kugou", "has_proxy_dependency": true, "description": "Kugou lyrics"},
|
{"id": LyricsProviderKugou, "name": "Kugou", "has_proxy_dependency": true, "description": "Kugou lyrics"},
|
||||||
{"id": LyricsProviderGenius, "name": "Genius", "has_proxy_dependency": true, "description": "Genius lyrics"},
|
{"id": LyricsProviderGenius, "name": "Genius", "has_proxy_dependency": true, "description": "Genius lyrics"},
|
||||||
|
{"id": LyricsProviderLyricsPlus, "name": "LyricsPlus", "has_proxy_dependency": true, "description": "Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ)"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,15 +630,22 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
|
|
||||||
if len(extensionProviders) > 0 {
|
if len(extensionProviders) > 0 {
|
||||||
for _, provider := range extensionProviders {
|
for _, provider := range extensionProviders {
|
||||||
|
providerName := "extension:" + provider.extension.ID
|
||||||
|
if skip, remaining, reason := shouldSkipLyricsProvider(providerName); skip {
|
||||||
|
GoLog("[Lyrics] Skipping unavailable extension lyrics provider %s for %s: %s\n", provider.extension.ID, remaining.Round(time.Second), reason)
|
||||||
|
continue
|
||||||
|
}
|
||||||
GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID)
|
GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID)
|
||||||
lyrics, err := provider.FetchLyrics(trackName, artistName, "", durationSec)
|
lyrics, err := provider.FetchLyrics(trackName, artistName, "", durationSec)
|
||||||
if err == nil && isValidResult(lyrics) {
|
if err == nil && isValidResult(lyrics) {
|
||||||
GoLog("[Lyrics] Got lyrics from extension: %s\n", provider.extension.ID)
|
GoLog("[Lyrics] Got lyrics from extension: %s\n", provider.extension.ID)
|
||||||
|
markLyricsProviderAvailable(providerName)
|
||||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Lyrics] Extension %s failed: %v\n", provider.extension.ID, err)
|
GoLog("[Lyrics] Extension %s failed: %v\n", provider.extension.ID, err)
|
||||||
|
markLyricsProviderUnavailable(providerName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,144 +659,338 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
|||||||
|
|
||||||
providerOrder := GetLyricsProviderOrder()
|
providerOrder := GetLyricsProviderOrder()
|
||||||
simplifiedTrack := simplifyTrackName(trackName)
|
simplifiedTrack := simplifyTrackName(trackName)
|
||||||
|
request := lyricsProviderSearchRequest{
|
||||||
|
spotifyID: spotifyID,
|
||||||
|
trackName: trackName,
|
||||||
|
artistName: artistName,
|
||||||
|
primaryArtist: primaryArtist,
|
||||||
|
simplifiedTrack: simplifiedTrack,
|
||||||
|
durationSec: durationSec,
|
||||||
|
fetchOptions: fetchOptions,
|
||||||
|
}
|
||||||
|
|
||||||
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
|
||||||
|
|
||||||
for _, providerName := range providerOrder {
|
lyrics, err := fetchBuiltInLyricsProviders(providerOrder, request, c.fetchBuiltInLyricsProvider)
|
||||||
GoLog("[Lyrics] Trying provider: %s\n", providerName)
|
if err == nil && isValidResult(lyrics) {
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
|
||||||
var lyrics *LyricsResponse
|
return nil, fmt.Errorf("lyrics not found from any source")
|
||||||
var err error
|
}
|
||||||
|
|
||||||
switch providerName {
|
func fetchBuiltInLyricsProviders(
|
||||||
case LyricsProviderLRCLIB:
|
providerOrder []string,
|
||||||
lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec)
|
request lyricsProviderSearchRequest,
|
||||||
|
fetchProvider func(string, lyricsProviderSearchRequest) (*LyricsResponse, error, bool),
|
||||||
|
) (*LyricsResponse, error) {
|
||||||
|
type providerCandidate struct {
|
||||||
|
index int
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
case LyricsProviderNetease:
|
candidates := make([]providerCandidate, 0, len(providerOrder))
|
||||||
neteaseClient := NewNeteaseClient()
|
results := make(chan lyricsProviderSearchResult, len(providerOrder))
|
||||||
lyrics, err = neteaseClient.FetchLyrics(
|
sem := make(chan struct{}, lyricsProviderParallelism)
|
||||||
trackName,
|
var wg sync.WaitGroup
|
||||||
primaryArtist,
|
|
||||||
durationSec,
|
|
||||||
fetchOptions.IncludeTranslationNetease,
|
|
||||||
fetchOptions.IncludeRomanizationNetease,
|
|
||||||
)
|
|
||||||
if err != nil && primaryArtist != artistName {
|
|
||||||
lyrics, err = neteaseClient.FetchLyrics(
|
|
||||||
trackName,
|
|
||||||
artistName,
|
|
||||||
durationSec,
|
|
||||||
fetchOptions.IncludeTranslationNetease,
|
|
||||||
fetchOptions.IncludeRomanizationNetease,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if err != nil && simplifiedTrack != trackName {
|
|
||||||
lyrics, err = neteaseClient.FetchLyrics(
|
|
||||||
simplifiedTrack,
|
|
||||||
primaryArtist,
|
|
||||||
durationSec,
|
|
||||||
fetchOptions.IncludeTranslationNetease,
|
|
||||||
fetchOptions.IncludeRomanizationNetease,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
case LyricsProviderMusixmatch:
|
for index, providerName := range providerOrder {
|
||||||
musixmatchClient := NewMusixmatchClient()
|
if skip, remaining, reason := shouldSkipLyricsProvider(providerName); skip {
|
||||||
lyrics, err = musixmatchClient.FetchLyrics(
|
GoLog("[Lyrics] Skipping unavailable provider %s for %s: %s\n", providerName, remaining.Round(time.Second), reason)
|
||||||
trackName,
|
continue
|
||||||
primaryArtist,
|
}
|
||||||
durationSec,
|
|
||||||
fetchOptions.MusixmatchLanguage,
|
|
||||||
)
|
|
||||||
if err != nil && primaryArtist != artistName {
|
|
||||||
lyrics, err = musixmatchClient.FetchLyrics(
|
|
||||||
trackName,
|
|
||||||
artistName,
|
|
||||||
durationSec,
|
|
||||||
fetchOptions.MusixmatchLanguage,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
case LyricsProviderAppleMusic:
|
knownProvider := isKnownBuiltInLyricsProvider(providerName)
|
||||||
appleClient := NewAppleMusicClient()
|
if !knownProvider {
|
||||||
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, fetchOptions.AppleElrcWordSync)
|
|
||||||
}
|
|
||||||
|
|
||||||
case LyricsProviderQQMusic:
|
|
||||||
qqClient := NewQQMusicClient()
|
|
||||||
lyrics, err = qqClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
|
|
||||||
if err != nil && primaryArtist != artistName {
|
|
||||||
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)
|
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil && isValidResult(lyrics) {
|
candidate := providerCandidate{index: index, name: providerName}
|
||||||
GoLog("[Lyrics] Got lyrics from: %s\n", providerName)
|
candidates = append(candidates, candidate)
|
||||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
wg.Add(1)
|
||||||
return lyrics, nil
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
|
||||||
|
GoLog("[Lyrics] Trying provider: %s\n", candidate.name)
|
||||||
|
lyrics, err, ok := fetchProvider(candidate.name, request)
|
||||||
|
if !ok {
|
||||||
|
results <- lyricsProviderSearchResult{index: candidate.index, providerName: candidate.name, err: fmt.Errorf("unknown provider")}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil && lyricsHasUsableText(lyrics) {
|
||||||
|
GoLog("[Lyrics] Got lyrics from: %s\n", candidate.name)
|
||||||
|
markLyricsProviderAvailable(candidate.name)
|
||||||
|
} else if err != nil {
|
||||||
|
GoLog("[Lyrics] Provider %s failed: %v\n", candidate.name, err)
|
||||||
|
markLyricsProviderUnavailable(candidate.name, err)
|
||||||
|
}
|
||||||
|
results <- lyricsProviderSearchResult{index: candidate.index, providerName: candidate.name, lyrics: lyrics, err: err}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil, fmt.Errorf("lyrics not found from any source")
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
}()
|
||||||
|
|
||||||
|
completed := make(map[int]bool, len(candidates))
|
||||||
|
var best *lyricsProviderSearchResult
|
||||||
|
var lastErr error
|
||||||
|
var graceTimer *time.Timer
|
||||||
|
var grace <-chan time.Time
|
||||||
|
|
||||||
|
stopGrace := func() {
|
||||||
|
if graceTimer != nil {
|
||||||
|
if !graceTimer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-graceTimer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
graceTimer = nil
|
||||||
|
grace = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer stopGrace()
|
||||||
|
|
||||||
|
hasPendingEarlier := func(index int) bool {
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if candidate.index >= index {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !completed[candidate.index] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for remaining := len(candidates); remaining > 0; {
|
||||||
|
if best != nil && !hasPendingEarlier(best.index) {
|
||||||
|
return best.lyrics, nil
|
||||||
|
}
|
||||||
|
if best != nil && graceTimer == nil {
|
||||||
|
graceTimer = time.NewTimer(lyricsProviderPriorityGrace)
|
||||||
|
grace = graceTimer.C
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
select {
|
||||||
GoLog("[Lyrics] Provider %s failed: %v\n", providerName, err)
|
case result, ok := <-results:
|
||||||
|
if !ok {
|
||||||
|
remaining = 0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
remaining--
|
||||||
|
completed[result.index] = true
|
||||||
|
if result.err != nil {
|
||||||
|
lastErr = result.err
|
||||||
|
}
|
||||||
|
if lyricsHasUsableText(result.lyrics) && (best == nil || result.index < best.index) {
|
||||||
|
copied := result
|
||||||
|
best = &copied
|
||||||
|
stopGrace()
|
||||||
|
}
|
||||||
|
case <-grace:
|
||||||
|
if best != nil {
|
||||||
|
GoLog("[Lyrics] Returning provider %s after %s priority grace\n", best.providerName, lyricsProviderPriorityGrace)
|
||||||
|
return best.lyrics, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if best != nil {
|
||||||
|
return best.lyrics, nil
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("lyrics not found from any source")
|
return nil, fmt.Errorf("lyrics not found from any source")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isKnownBuiltInLyricsProvider(providerName string) bool {
|
||||||
|
switch providerName {
|
||||||
|
case LyricsProviderLRCLIB,
|
||||||
|
LyricsProviderNetease,
|
||||||
|
LyricsProviderMusixmatch,
|
||||||
|
LyricsProviderAppleMusic,
|
||||||
|
LyricsProviderQQMusic,
|
||||||
|
LyricsProviderSpotify,
|
||||||
|
LyricsProviderDeezer,
|
||||||
|
LyricsProviderYouTube,
|
||||||
|
LyricsProviderKugou,
|
||||||
|
LyricsProviderGenius,
|
||||||
|
LyricsProviderLyricsPlus:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LyricsClient) fetchBuiltInLyricsProvider(providerName string, request lyricsProviderSearchRequest) (*LyricsResponse, error, bool) {
|
||||||
|
switch providerName {
|
||||||
|
case LyricsProviderLRCLIB:
|
||||||
|
lyrics, err := c.tryLRCLIB(request.primaryArtist, request.artistName, request.trackName, request.simplifiedTrack, request.durationSec)
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderNetease:
|
||||||
|
neteaseClient := NewNeteaseClient()
|
||||||
|
lyrics, err := neteaseClient.FetchLyrics(
|
||||||
|
request.trackName,
|
||||||
|
request.primaryArtist,
|
||||||
|
request.durationSec,
|
||||||
|
request.fetchOptions.IncludeTranslationNetease,
|
||||||
|
request.fetchOptions.IncludeRomanizationNetease,
|
||||||
|
)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = neteaseClient.FetchLyrics(
|
||||||
|
request.trackName,
|
||||||
|
request.artistName,
|
||||||
|
request.durationSec,
|
||||||
|
request.fetchOptions.IncludeTranslationNetease,
|
||||||
|
request.fetchOptions.IncludeRomanizationNetease,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
|
||||||
|
lyrics, err = neteaseClient.FetchLyrics(
|
||||||
|
request.simplifiedTrack,
|
||||||
|
request.primaryArtist,
|
||||||
|
request.durationSec,
|
||||||
|
request.fetchOptions.IncludeTranslationNetease,
|
||||||
|
request.fetchOptions.IncludeRomanizationNetease,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderMusixmatch:
|
||||||
|
musixmatchClient := NewMusixmatchClient()
|
||||||
|
lyrics, err := musixmatchClient.FetchLyrics(
|
||||||
|
request.trackName,
|
||||||
|
request.primaryArtist,
|
||||||
|
request.durationSec,
|
||||||
|
request.fetchOptions.MusixmatchLanguage,
|
||||||
|
)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = musixmatchClient.FetchLyrics(
|
||||||
|
request.trackName,
|
||||||
|
request.artistName,
|
||||||
|
request.durationSec,
|
||||||
|
request.fetchOptions.MusixmatchLanguage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderAppleMusic:
|
||||||
|
appleClient := NewAppleMusicClient()
|
||||||
|
lyrics, err := appleClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec, request.fetchOptions.MultiPersonWordByWord, request.fetchOptions.AppleElrcWordSync)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = appleClient.FetchLyrics(request.trackName, request.artistName, request.durationSec, request.fetchOptions.MultiPersonWordByWord, request.fetchOptions.AppleElrcWordSync)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderQQMusic:
|
||||||
|
qqClient := NewQQMusicClient()
|
||||||
|
lyrics, err := qqClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec, request.fetchOptions.MultiPersonWordByWord)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = qqClient.FetchLyrics(request.trackName, request.artistName, request.durationSec, request.fetchOptions.MultiPersonWordByWord)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderSpotify:
|
||||||
|
spotifyClient := NewSpotifyLyricsClient()
|
||||||
|
lyrics, err := spotifyClient.FetchLyrics(request.spotifyID, request.trackName, request.primaryArtist, request.durationSec)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = spotifyClient.FetchLyrics(request.spotifyID, request.trackName, request.artistName, request.durationSec)
|
||||||
|
}
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
|
||||||
|
lyrics, err = spotifyClient.FetchLyrics("", request.simplifiedTrack, request.primaryArtist, request.durationSec)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderDeezer:
|
||||||
|
deezerClient := NewDeezerLyricsClient()
|
||||||
|
lyrics, err := deezerClient.FetchLyrics(request.spotifyID, request.trackName, request.primaryArtist, request.durationSec)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = deezerClient.FetchLyrics(request.spotifyID, request.trackName, request.artistName, request.durationSec)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderYouTube:
|
||||||
|
youtubeClient := NewYouTubeLyricsClient()
|
||||||
|
lyrics, err := youtubeClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = youtubeClient.FetchLyrics(request.trackName, request.artistName, request.durationSec)
|
||||||
|
}
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
|
||||||
|
lyrics, err = youtubeClient.FetchLyrics(request.simplifiedTrack, request.primaryArtist, request.durationSec)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderKugou:
|
||||||
|
kugouClient := NewKugouLyricsClient()
|
||||||
|
lyrics, err := kugouClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = kugouClient.FetchLyrics(request.trackName, request.artistName, request.durationSec)
|
||||||
|
}
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
|
||||||
|
lyrics, err = kugouClient.FetchLyrics(request.simplifiedTrack, request.primaryArtist, request.durationSec)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderGenius:
|
||||||
|
geniusClient := NewGeniusLyricsClient()
|
||||||
|
lyrics, err := geniusClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = geniusClient.FetchLyrics(request.trackName, request.artistName, request.durationSec)
|
||||||
|
}
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
|
||||||
|
lyrics, err = geniusClient.FetchLyrics(request.simplifiedTrack, request.primaryArtist, request.durationSec)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
|
||||||
|
case LyricsProviderLyricsPlus:
|
||||||
|
lyricsPlusClient := NewLyricsPlusClient()
|
||||||
|
lyrics, err := lyricsPlusClient.FetchLyrics(
|
||||||
|
request.trackName,
|
||||||
|
request.primaryArtist,
|
||||||
|
"",
|
||||||
|
request.durationSec,
|
||||||
|
request.fetchOptions.MultiPersonWordByWord,
|
||||||
|
request.fetchOptions.AppleElrcWordSync,
|
||||||
|
)
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName {
|
||||||
|
lyrics, err = lyricsPlusClient.FetchLyrics(
|
||||||
|
request.trackName,
|
||||||
|
request.artistName,
|
||||||
|
"",
|
||||||
|
request.durationSec,
|
||||||
|
request.fetchOptions.MultiPersonWordByWord,
|
||||||
|
request.fetchOptions.AppleElrcWordSync,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName {
|
||||||
|
lyrics, err = lyricsPlusClient.FetchLyrics(
|
||||||
|
request.simplifiedTrack,
|
||||||
|
request.primaryArtist,
|
||||||
|
"",
|
||||||
|
request.durationSec,
|
||||||
|
request.fetchOptions.MultiPersonWordByWord,
|
||||||
|
request.fetchOptions.AppleElrcWordSync,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return lyrics, err, true
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown provider: %s", providerName), false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) {
|
func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) {
|
||||||
var lyrics *LyricsResponse
|
var lyrics *LyricsResponse
|
||||||
var err error
|
var err error
|
||||||
@@ -640,6 +1000,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
|||||||
lyrics.Source = "LRCLIB"
|
lyrics.Source = "LRCLIB"
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
if isLyricsProviderUnavailableError(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if primaryArtist != artistName {
|
if primaryArtist != artistName {
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||||
@@ -647,6 +1010,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
|||||||
lyrics.Source = "LRCLIB"
|
lyrics.Source = "LRCLIB"
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
if isLyricsProviderUnavailableError(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
@@ -655,6 +1021,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
|||||||
lyrics.Source = "LRCLIB (simplified)"
|
lyrics.Source = "LRCLIB (simplified)"
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
if isLyricsProviderUnavailableError(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
query := primaryArtist + " " + trackName
|
query := primaryArtist + " " + trackName
|
||||||
@@ -663,6 +1032,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
|||||||
lyrics.Source = "LRCLIB Search"
|
lyrics.Source = "LRCLIB Search"
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
if isLyricsProviderUnavailableError(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
query = primaryArtist + " " + simplifiedTrack
|
query = primaryArtist + " " + simplifiedTrack
|
||||||
@@ -671,6 +1043,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
|||||||
lyrics.Source = "LRCLIB Search (simplified)"
|
lyrics.Source = "LRCLIB Search (simplified)"
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
if isLyricsProviderUnavailableError(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("LRCLIB: no lyrics found")
|
return nil, fmt.Errorf("LRCLIB: no lyrics found")
|
||||||
@@ -814,6 +1189,18 @@ func detectLyricsErrorPayload(raw string) (string, bool) {
|
|||||||
if success, ok := payload["success"].(bool); ok && !success && !hasLyricsKey {
|
if success, ok := payload["success"].(bool); ok && !success && !hasLyricsKey {
|
||||||
return "request unsuccessful", true
|
return "request unsuccessful", true
|
||||||
}
|
}
|
||||||
|
if isError, ok := payload["isError"].(bool); ok && isError && !hasLyricsKey {
|
||||||
|
return "request unsuccessful", true
|
||||||
|
}
|
||||||
|
if code, ok := payload["code"].(float64); ok && code != 0 && code != 200 && !hasLyricsKey {
|
||||||
|
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||||
|
return strings.TrimSpace(msg), true
|
||||||
|
}
|
||||||
|
if msg, ok := payload["msg"].(string); ok && strings.TrimSpace(msg) != "" {
|
||||||
|
return strings.TrimSpace(msg), true
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("unexpected response code %.0f", code), true
|
||||||
|
}
|
||||||
|
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -843,6 +1230,41 @@ func msToLRCTimestampInline(ms int64) string {
|
|||||||
return fmt.Sprintf("%02d:%02d.%02d", minutes, seconds, centiseconds)
|
return fmt.Sprintf("%02d:%02d.%02d", minutes, seconds, centiseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractLyricsSourceFromLRC reads the provider recorded in the LRC [by:] tag,
|
||||||
|
// e.g. "[by:SpotiFLAC-Mobile (source: LRCLIB)]". Returns "" when absent.
|
||||||
|
const lrcSourceMarker = "(source: "
|
||||||
|
|
||||||
|
func lyricsSourceUsesPaxsenix(source string) bool {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(source))
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(s, "lrclib") ||
|
||||||
|
strings.HasPrefix(s, "extension:") ||
|
||||||
|
strings.HasPrefix(s, "heuristic") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractLyricsSourceFromLRC(lrc string) string {
|
||||||
|
for _, line := range strings.Split(lrc, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if !strings.HasPrefix(strings.ToLower(trimmed), "[by:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := strings.Index(trimmed, lrcSourceMarker)
|
||||||
|
if idx < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := strings.TrimSpace(trimmed[idx+len(lrcSourceMarker):])
|
||||||
|
rest = strings.TrimSuffix(rest, "]")
|
||||||
|
rest = strings.TrimSuffix(rest, ")")
|
||||||
|
return strings.TrimSpace(rest)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName string) string {
|
||||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -852,7 +1274,21 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
|
|||||||
|
|
||||||
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||||
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
|
||||||
builder.WriteString("[by:Implemented by SpotiFLAC-Mobile using Paxsenix API]\n")
|
source := strings.TrimSpace(lyrics.Source)
|
||||||
|
if source == "" {
|
||||||
|
source = strings.TrimSpace(lyrics.Provider)
|
||||||
|
}
|
||||||
|
credit := "SpotiFLAC-Mobile"
|
||||||
|
if lyricsSourceUsesPaxsenix(source) {
|
||||||
|
credit = "SpotiFLAC-Mobile via Paxsenix API"
|
||||||
|
}
|
||||||
|
if source == "" {
|
||||||
|
builder.WriteString(fmt.Sprintf("[by:%s]\n", credit))
|
||||||
|
} else {
|
||||||
|
builder.WriteString(
|
||||||
|
fmt.Sprintf("[by:%s %s%s)]\n", credit, lrcSourceMarker, source),
|
||||||
|
)
|
||||||
|
}
|
||||||
builder.WriteString("\n")
|
builder.WriteString("\n")
|
||||||
|
|
||||||
if lyrics.SyncType == "LINE_SYNCED" {
|
if lyrics.SyncType == "LINE_SYNCED" {
|
||||||
|
|||||||
+216
-23
@@ -2,19 +2,26 @@ package gobackend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errAppleMusicUnauthorized = errors.New("apple music catalog search unauthorized")
|
||||||
|
|
||||||
type AppleMusicClient struct {
|
type AppleMusicClient struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const appleMusicCatalogBaseURL = "https://amp-api.music.apple.com/v1/catalog/us"
|
||||||
|
|
||||||
type appleMusicSearchResult struct {
|
type appleMusicSearchResult struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
SongName string `json:"songName"`
|
SongName string `json:"songName"`
|
||||||
@@ -23,9 +30,33 @@ type appleMusicSearchResult struct {
|
|||||||
Duration int `json:"duration"`
|
Duration int `json:"duration"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type appleMusicCatalogSearchResponse struct {
|
||||||
|
Results struct {
|
||||||
|
Songs *struct {
|
||||||
|
Data []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
} `json:"songs"`
|
||||||
|
} `json:"results"`
|
||||||
|
Resources *struct {
|
||||||
|
Songs map[string]struct {
|
||||||
|
Attributes struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ArtistName string `json:"artistName"`
|
||||||
|
AlbumName string `json:"albumName"`
|
||||||
|
DurationInMillis int `json:"durationInMillis"`
|
||||||
|
} `json:"attributes"`
|
||||||
|
} `json:"songs"`
|
||||||
|
} `json:"resources"`
|
||||||
|
}
|
||||||
|
|
||||||
type paxResponse struct {
|
type paxResponse struct {
|
||||||
Type string `json:"type"` // "Syllable" or "Line"
|
Type string `json:"type"` // "Syllable" or "Line"
|
||||||
Content []paxLyrics `json:"content"` // List of lyric lines
|
Content []paxLyrics `json:"content"`
|
||||||
|
ELRC string `json:"elrc"`
|
||||||
|
ELRCMultiPerson string `json:"elrcMultiPerson"`
|
||||||
|
Plain string `json:"plain"`
|
||||||
|
TTMLContent string `json:"ttmlContent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type paxLyrics struct {
|
type paxLyrics struct {
|
||||||
@@ -44,6 +75,11 @@ type paxLyricDetail struct {
|
|||||||
EndTime *int `json:"endtime"`
|
EndTime *int `json:"endtime"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
appleMusicTokenMu sync.Mutex
|
||||||
|
appleMusicCachedToken string
|
||||||
|
)
|
||||||
|
|
||||||
func NewAppleMusicClient() *AppleMusicClient {
|
func NewAppleMusicClient() *AppleMusicClient {
|
||||||
return &AppleMusicClient{
|
return &AppleMusicClient{
|
||||||
httpClient: NewMetadataHTTPClient(20 * time.Second),
|
httpClient: NewMetadataHTTPClient(20 * time.Second),
|
||||||
@@ -100,36 +136,164 @@ func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackNam
|
|||||||
return &results[bestIndex]
|
return &results[bestIndex]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *AppleMusicClient) getAppleMusicToken() (string, error) {
|
||||||
|
appleMusicTokenMu.Lock()
|
||||||
|
defer appleMusicTokenMu.Unlock()
|
||||||
|
|
||||||
|
if appleMusicCachedToken != "" {
|
||||||
|
return appleMusicCachedToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create apple music page request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to fetch apple music page: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("apple music page returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read apple music page: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
indexPath := regexp.MustCompile(`/assets/index~[^"' <]+\.js`).FindString(string(body))
|
||||||
|
if indexPath == "" {
|
||||||
|
return "", fmt.Errorf("apple music index script not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
jsReq, err := http.NewRequest("GET", "https://beta.music.apple.com"+indexPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create apple music script request: %w", err)
|
||||||
|
}
|
||||||
|
jsReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
|
||||||
|
jsResp, err := c.httpClient.Do(jsReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to fetch apple music script: %w", err)
|
||||||
|
}
|
||||||
|
defer jsResp.Body.Close()
|
||||||
|
|
||||||
|
if jsResp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("apple music script returned HTTP %d", jsResp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsBody, err := io.ReadAll(jsResp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read apple music script: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`).FindString(string(jsBody))
|
||||||
|
if token == "" {
|
||||||
|
return "", fmt.Errorf("apple music token not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
appleMusicCachedToken = token
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAppleMusicToken() {
|
||||||
|
appleMusicTokenMu.Lock()
|
||||||
|
defer appleMusicTokenMu.Unlock()
|
||||||
|
appleMusicCachedToken = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AppleMusicClient) searchSongWithToken(token, query string) ([]appleMusicSearchResult, error) {
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("term", query)
|
||||||
|
params.Set("types", "songs")
|
||||||
|
params.Set("limit", "25")
|
||||||
|
params.Set("l", "en-US")
|
||||||
|
params.Set("platform", "web")
|
||||||
|
params.Set("format[resources]", "map")
|
||||||
|
params.Set("include[songs]", "artists")
|
||||||
|
params.Set("extend", "artistUrl")
|
||||||
|
|
||||||
|
searchURL := appleMusicCatalogBaseURL + "/search?" + params.Encode()
|
||||||
|
req, err := http.NewRequest("GET", searchURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create apple music catalog request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Origin", "https://music.apple.com")
|
||||||
|
req.Header.Set("Referer", "https://music.apple.com/")
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
|
||||||
|
req.Header.Set("x-apple-renewal", "true")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("apple music catalog search failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
return nil, errAppleMusicUnauthorized
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("apple music catalog search returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp appleMusicCatalogSearchResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode apple music catalog response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if searchResp.Results.Songs == nil || searchResp.Resources == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]appleMusicSearchResult, 0, len(searchResp.Results.Songs.Data))
|
||||||
|
for _, item := range searchResp.Results.Songs.Data {
|
||||||
|
detail, ok := searchResp.Resources.Songs[item.ID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
attr := detail.Attributes
|
||||||
|
results = append(results, appleMusicSearchResult{
|
||||||
|
ID: item.ID,
|
||||||
|
SongName: attr.Name,
|
||||||
|
ArtistName: attr.ArtistName,
|
||||||
|
AlbumName: attr.AlbumName,
|
||||||
|
Duration: attr.DurationInMillis,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||||
query := trackName + " " + artistName
|
query := trackName + " " + artistName
|
||||||
if strings.TrimSpace(query) == "" {
|
if strings.TrimSpace(query) == "" {
|
||||||
return "", fmt.Errorf("empty search query")
|
return "", fmt.Errorf("empty search query")
|
||||||
}
|
}
|
||||||
|
|
||||||
encodedQuery := url.QueryEscape(query)
|
token, err := c.getAppleMusicToken()
|
||||||
searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", searchURL, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create request: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", appUserAgent())
|
searchResp, err := c.searchSongWithToken(token, strings.TrimSpace(query))
|
||||||
req.Header.Set("Accept", "application/json")
|
if errors.Is(err, errAppleMusicUnauthorized) {
|
||||||
|
clearAppleMusicToken()
|
||||||
resp, err := c.httpClient.Do(req)
|
token, tokenErr := c.getAppleMusicToken()
|
||||||
|
if tokenErr != nil {
|
||||||
|
return "", tokenErr
|
||||||
|
}
|
||||||
|
searchResp, err = c.searchSongWithToken(token, strings.TrimSpace(query))
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("apple music search failed: %w", err)
|
return "", err
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var searchResp []appleMusicSearchResult
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to decode apple music response: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
|
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
|
||||||
@@ -174,8 +338,33 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool, preserveWordTiming bool) (string, error) {
|
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool, preserveWordTiming bool) (string, error) {
|
||||||
|
var stringPayload string
|
||||||
|
if err := json.Unmarshal([]byte(rawJSON), &stringPayload); err == nil {
|
||||||
|
stringPayload = strings.TrimSpace(stringPayload)
|
||||||
|
if stringPayload != "" {
|
||||||
|
return stringPayload, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var paxResp paxResponse
|
var paxResp paxResponse
|
||||||
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
|
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil &&
|
||||||
|
(paxResp.Content != nil ||
|
||||||
|
strings.TrimSpace(paxResp.ELRCMultiPerson) != "" ||
|
||||||
|
strings.TrimSpace(paxResp.ELRC) != "" ||
|
||||||
|
strings.TrimSpace(paxResp.Plain) != "" ||
|
||||||
|
strings.TrimSpace(paxResp.TTMLContent) != "") {
|
||||||
|
if preserveWordTiming && multiPersonWordByWord && strings.TrimSpace(paxResp.ELRCMultiPerson) != "" {
|
||||||
|
return strings.TrimSpace(paxResp.ELRCMultiPerson), nil
|
||||||
|
}
|
||||||
|
if preserveWordTiming && strings.TrimSpace(paxResp.ELRC) != "" {
|
||||||
|
return strings.TrimSpace(paxResp.ELRC), nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(paxResp.Plain) != "" && len(paxResp.Content) == 0 {
|
||||||
|
return strings.TrimSpace(paxResp.Plain), nil
|
||||||
|
}
|
||||||
|
if len(paxResp.Content) == 0 {
|
||||||
|
return "", fmt.Errorf("unsupported apple music lyrics payload")
|
||||||
|
}
|
||||||
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord, preserveWordTiming), nil
|
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord, preserveWordTiming), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +459,10 @@ func (c *AppleMusicClient) FetchLyrics(
|
|||||||
|
|
||||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, preserveWordTiming)
|
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, preserveWordTiming)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
trimmedRaw := strings.TrimSpace(rawLyrics)
|
||||||
|
if strings.HasPrefix(trimmedRaw, "{") || strings.HasPrefix(trimmedRaw, "[") {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
lrcText = rawLyrics
|
lrcText = rawLyrics
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LyricsPlus (KPOE) provider.
|
||||||
|
//
|
||||||
|
// LyricsPlus aggregates word-by-word ("karaoke") synced lyrics from Apple
|
||||||
|
// Music, Musixmatch, Spotify and QQ Music via a community-run backend. It
|
||||||
|
// frequently has word-level timing for tracks that other providers only offer
|
||||||
|
// line-synced or not at all.
|
||||||
|
//
|
||||||
|
// API: GET {server}/v2/lyrics/get?title=&artist=&album=&duration=&isrc=
|
||||||
|
// The response is the KPOE JSON format which we convert into the same enhanced
|
||||||
|
// LRC text the Apple/QQ providers emit, so embedding/export behaves identically.
|
||||||
|
|
||||||
|
// Public LyricsPlus / KPOE servers (mirrors). Tried in order with failover.
|
||||||
|
// Sourced from the upstream YouLy+ client server list.
|
||||||
|
var lyricsPlusServers = []string{
|
||||||
|
"https://lyricsplus.prjktla.workers.dev",
|
||||||
|
"https://lyricsplus.binimum.org",
|
||||||
|
}
|
||||||
|
|
||||||
|
type LyricsPlusClient struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLyricsPlusClient() *LyricsPlusClient {
|
||||||
|
return &LyricsPlusClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
|
||||||
|
}
|
||||||
|
|
||||||
|
type lyricsPlusSyllable struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Time float64 `json:"time"` // absolute ms
|
||||||
|
Duration float64 `json:"duration"` // ms
|
||||||
|
IsBackground bool `json:"isBackground"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type lyricsPlusLine struct {
|
||||||
|
Time float64 `json:"time"` // absolute ms
|
||||||
|
Duration float64 `json:"duration"` // ms
|
||||||
|
Text string `json:"text"`
|
||||||
|
Syllabus []lyricsPlusSyllable `json:"syllabus"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type lyricsPlusResponse struct {
|
||||||
|
Type string `json:"type"` // "Word" | "Line" | "Syllable" | "None"
|
||||||
|
Lyrics []lyricsPlusLine `json:"lyrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLyrics tries each LyricsPlus server in order until one returns usable
|
||||||
|
// lyrics. multiPersonWordByWord and preserveWordTiming mirror the Apple/QQ
|
||||||
|
// options so word/background timing is only emitted when the user enabled it.
|
||||||
|
func (c *LyricsPlusClient) FetchLyrics(
|
||||||
|
trackName,
|
||||||
|
artistName,
|
||||||
|
isrc string,
|
||||||
|
durationSec float64,
|
||||||
|
multiPersonWordByWord bool,
|
||||||
|
preserveWordTiming bool,
|
||||||
|
) (*LyricsResponse, error) {
|
||||||
|
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" {
|
||||||
|
return nil, fmt.Errorf("lyricsplus: missing track or artist")
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, server := range lyricsPlusServers {
|
||||||
|
lyrics, err := c.fetchFromServer(server, trackName, artistName, isrc, durationSec, multiPersonWordByWord, preserveWordTiming)
|
||||||
|
if err == nil && lyricsHasUsableText(lyrics) {
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
GoLog("[Lyrics] LyricsPlus server %s failed: %v\n", server, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr != nil {
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("lyricsplus: no lyrics found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LyricsPlusClient) fetchFromServer(
|
||||||
|
server,
|
||||||
|
trackName,
|
||||||
|
artistName,
|
||||||
|
isrc string,
|
||||||
|
durationSec float64,
|
||||||
|
multiPersonWordByWord bool,
|
||||||
|
preserveWordTiming bool,
|
||||||
|
) (*LyricsResponse, error) {
|
||||||
|
base := strings.TrimRight(strings.TrimSpace(server), "/")
|
||||||
|
if base == "" {
|
||||||
|
return nil, fmt.Errorf("empty server")
|
||||||
|
}
|
||||||
|
|
||||||
|
params := url.Values{}
|
||||||
|
params.Set("title", trackName)
|
||||||
|
params.Set("artist", artistName)
|
||||||
|
if durationSec > 0 {
|
||||||
|
params.Set("duration", strconv.FormatFloat(durationSec, 'f', 3, 64))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(isrc) != "" {
|
||||||
|
params.Set("isrc", strings.TrimSpace(isrc))
|
||||||
|
}
|
||||||
|
|
||||||
|
fullURL := base + "/v2/lyrics/get?" + params.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", fullURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", appUserAgent())
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
// Retry without the ISRC filter, which can be too strict.
|
||||||
|
if strings.TrimSpace(isrc) != "" {
|
||||||
|
return c.fetchFromServer(server, trackName, artistName, "", durationSec, multiPersonWordByWord, preserveWordTiming)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("lyrics not found")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload lyricsPlusResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode lyricsplus response: %w", err)
|
||||||
|
}
|
||||||
|
if len(payload.Lyrics) == 0 {
|
||||||
|
return nil, fmt.Errorf("lyricsplus returned no lines")
|
||||||
|
}
|
||||||
|
|
||||||
|
lrcText := buildLyricsPlusLRC(&payload, multiPersonWordByWord, preserveWordTiming)
|
||||||
|
if strings.TrimSpace(lrcText) == "" {
|
||||||
|
return nil, fmt.Errorf("lyricsplus produced empty lyrics")
|
||||||
|
}
|
||||||
|
|
||||||
|
lyrics := lyricsResponseFromText(lrcText, "LyricsPlus")
|
||||||
|
return lyrics, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildLyricsPlusLRC converts the KPOE JSON into enhanced LRC text. When word
|
||||||
|
// timing is available and enabled, each syllable is emitted as an inline
|
||||||
|
// <mm:ss.xx> tag (matching the Apple/QQ output); otherwise a line-synced LRC
|
||||||
|
// is produced from the full line text.
|
||||||
|
func buildLyricsPlusLRC(resp *lyricsPlusResponse, multiPersonWordByWord bool, preserveWordTiming bool) string {
|
||||||
|
isWordType := strings.EqualFold(resp.Type, "Word") || strings.EqualFold(resp.Type, "Syllable")
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
first := true
|
||||||
|
for _, line := range resp.Lyrics {
|
||||||
|
lineText := line.Text
|
||||||
|
hasSyllables := len(line.Syllabus) > 0
|
||||||
|
|
||||||
|
timestamp := msToLRCTimestamp(int64(line.Time))
|
||||||
|
|
||||||
|
if isWordType && preserveWordTiming && hasSyllables {
|
||||||
|
mainSyllables := make([]lyricsPlusSyllable, 0, len(line.Syllabus))
|
||||||
|
bgSyllables := make([]lyricsPlusSyllable, 0)
|
||||||
|
for _, syl := range line.Syllabus {
|
||||||
|
if syl.IsBackground {
|
||||||
|
bgSyllables = append(bgSyllables, syl)
|
||||||
|
} else {
|
||||||
|
mainSyllables = append(mainSyllables, syl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mainSyllables) == 0 {
|
||||||
|
mainSyllables = line.Syllabus
|
||||||
|
bgSyllables = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !first {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
|
||||||
|
sb.WriteString(timestamp)
|
||||||
|
appendLyricsPlusSyllables(&sb, mainSyllables)
|
||||||
|
|
||||||
|
if multiPersonWordByWord && len(bgSyllables) > 0 {
|
||||||
|
sb.WriteString("\n[bg:")
|
||||||
|
appendLyricsPlusSyllables(&sb, bgSyllables)
|
||||||
|
sb.WriteString("]")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line-synced fallback. Reconstruct text from syllables if needed.
|
||||||
|
if strings.TrimSpace(lineText) == "" && hasSyllables {
|
||||||
|
var lineBuilder strings.Builder
|
||||||
|
for _, syl := range line.Syllabus {
|
||||||
|
lineBuilder.WriteString(syl.Text)
|
||||||
|
}
|
||||||
|
lineText = lineBuilder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
lineText = strings.TrimSpace(lineText)
|
||||||
|
if lineText == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !first {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
|
||||||
|
sb.WriteString(timestamp)
|
||||||
|
sb.WriteString(lineText)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendLyricsPlusSyllables writes each syllable as "<mm:ss.xx>text". KPOE
|
||||||
|
// already embeds spacing inside the syllable text, so no extra spaces are added.
|
||||||
|
func appendLyricsPlusSyllables(sb *strings.Builder, syllables []lyricsPlusSyllable) {
|
||||||
|
for _, syl := range syllables {
|
||||||
|
sb.WriteString("<")
|
||||||
|
sb.WriteString(msToLRCTimestampInline(int64(syl.Time)))
|
||||||
|
sb.WriteString(">")
|
||||||
|
sb.WriteString(syl.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,9 @@ type neteaseSearchResponse struct {
|
|||||||
} `json:"songs"`
|
} `json:"songs"`
|
||||||
SongCount int `json:"songCount"`
|
SongCount int `json:"songCount"`
|
||||||
} `json:"result"`
|
} `json:"result"`
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type neteaseLyricsResponse struct {
|
type neteaseLyricsResponse struct {
|
||||||
@@ -87,6 +89,17 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
|
|||||||
return 0, fmt.Errorf("failed to decode netease search: %w", err)
|
return 0, fmt.Errorf("failed to decode netease search: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if searchResp.Code != 0 && searchResp.Code != 200 {
|
||||||
|
message := strings.TrimSpace(searchResp.Message)
|
||||||
|
if message == "" {
|
||||||
|
message = strings.TrimSpace(searchResp.Msg)
|
||||||
|
}
|
||||||
|
if message == "" {
|
||||||
|
message = "unexpected response code"
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("netease search unavailable: code %d: %s", searchResp.Code, message)
|
||||||
|
}
|
||||||
|
|
||||||
if searchResp.Result.SongCount == 0 || len(searchResp.Result.Songs) == 0 {
|
if searchResp.Result.SongCount == 0 || len(searchResp.Result.Songs) == 0 {
|
||||||
return 0, fmt.Errorf("no songs found on netease")
|
return 0, fmt.Errorf("no songs found on netease")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -463,7 +463,7 @@ func (c *GeniusLyricsClient) SearchSong(trackName, artistName string, durationSe
|
|||||||
|
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("q", query)
|
params.Set("q", query)
|
||||||
params.Set("per_page", "10")
|
params.Set("per_page", "5")
|
||||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://genius.com/api/search/multi", params)
|
raw, err := fetchPaxsenixBody(c.httpClient, "https://genius.com/api/search/multi", params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("genius search failed: %w", err)
|
return "", fmt.Errorf("genius search failed: %w", err)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package gobackend
|
package gobackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -54,6 +55,15 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
|
|||||||
if msg, ok := detectLyricsErrorPayload(`{"success":false,"message":"nope"}`); !ok || msg != "nope" {
|
if msg, ok := detectLyricsErrorPayload(`{"success":false,"message":"nope"}`); !ok || msg != "nope" {
|
||||||
t.Fatalf("error payload = %q/%v", msg, ok)
|
t.Fatalf("error payload = %q/%v", msg, ok)
|
||||||
}
|
}
|
||||||
|
if msg, ok := detectLyricsErrorPayload(`{"isError":true,"error":"Missing required parameters"}`); !ok || msg != "Missing required parameters" {
|
||||||
|
t.Fatalf("isError payload = %q/%v", msg, ok)
|
||||||
|
}
|
||||||
|
if msg, ok := detectLyricsErrorPayload(`{"code":405,"message":"rate limited"}`); !ok || msg != "rate limited" {
|
||||||
|
t.Fatalf("coded error payload = %q/%v", msg, ok)
|
||||||
|
}
|
||||||
|
if !isLyricsProviderUnavailableError(errors.New("rate limit")) {
|
||||||
|
t.Fatal("expected rate-limit errors to mark provider unavailable")
|
||||||
|
}
|
||||||
if lrcTimestampToMs("01", "02", "345") != 62345 || msToLRCTimestamp(62340) != "[01:02.34]" {
|
if lrcTimestampToMs("01", "02", "345") != 62345 || msToLRCTimestamp(62340) != "[01:02.34]" {
|
||||||
t.Fatal("unexpected LRC timestamp conversion")
|
t.Fatal("unexpected LRC timestamp conversion")
|
||||||
}
|
}
|
||||||
@@ -130,15 +140,130 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLyricsProviderHealthSkipsUnavailableProvider(t *testing.T) {
|
||||||
|
SetLyricsProviderOrder([]string{LyricsProviderLRCLIB})
|
||||||
|
defer SetLyricsProviderOrder(nil)
|
||||||
|
globalLyricsCache.ClearAll()
|
||||||
|
clearLyricsProviderHealth()
|
||||||
|
defer clearLyricsProviderHealth()
|
||||||
|
|
||||||
|
calls := 0
|
||||||
|
downClient := &LyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
calls++
|
||||||
|
return &http.Response{StatusCode: 503, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`service unavailable`)), Request: req}, nil
|
||||||
|
})}}
|
||||||
|
|
||||||
|
if lyrics, err := downClient.FetchLyricsAllSources("", "Down Song", "Artist", 180); err == nil || lyrics != nil {
|
||||||
|
t.Fatalf("expected unavailable provider error, got %#v/%v", lyrics, err)
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("expected one HTTP call before cooldown, got %d", calls)
|
||||||
|
}
|
||||||
|
if skip, _, _ := shouldSkipLyricsProvider(LyricsProviderLRCLIB); !skip {
|
||||||
|
t.Fatal("expected LRCLIB to be marked unavailable")
|
||||||
|
}
|
||||||
|
if lyrics, err := downClient.FetchLyricsAllSources("", "Another Song", "Artist", 180); err == nil || lyrics != nil {
|
||||||
|
t.Fatalf("expected skipped provider error, got %#v/%v", lyrics, err)
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("provider was called while in cooldown, calls=%d", calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLyricsProviderHealth()
|
||||||
|
globalLyricsCache.ClearAll()
|
||||||
|
notFoundCalls := 0
|
||||||
|
notFoundClient := &LyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
notFoundCalls++
|
||||||
|
switch req.URL.Path {
|
||||||
|
case "/api/get":
|
||||||
|
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||||
|
case "/api/search":
|
||||||
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[]`)), Request: req}, nil
|
||||||
|
default:
|
||||||
|
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||||
|
}
|
||||||
|
})}}
|
||||||
|
|
||||||
|
if lyrics, err := notFoundClient.FetchLyricsAllSources("", "missing song", "Artist", 180); err == nil || lyrics != nil {
|
||||||
|
t.Fatalf("expected not found error, got %#v/%v", lyrics, err)
|
||||||
|
}
|
||||||
|
if skip, _, _ := shouldSkipLyricsProvider(LyricsProviderLRCLIB); skip {
|
||||||
|
t.Fatal("not-found result must not mark provider unavailable")
|
||||||
|
}
|
||||||
|
if lyrics, err := notFoundClient.FetchLyricsAllSources("", "missing song 2", "Artist", 180); err == nil || lyrics != nil {
|
||||||
|
t.Fatalf("expected second not found error, got %#v/%v", lyrics, err)
|
||||||
|
}
|
||||||
|
if notFoundCalls != 4 {
|
||||||
|
t.Fatalf("expected not-found provider to be retried, calls=%d", notFoundCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentLyricsProvidersReturnFastFallback(t *testing.T) {
|
||||||
|
clearLyricsProviderHealth()
|
||||||
|
defer clearLyricsProviderHealth()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
lyrics, err := fetchBuiltInLyricsProviders(
|
||||||
|
[]string{LyricsProviderLRCLIB, LyricsProviderAppleMusic},
|
||||||
|
lyricsProviderSearchRequest{},
|
||||||
|
func(providerName string, _ lyricsProviderSearchRequest) (*LyricsResponse, error, bool) {
|
||||||
|
if providerName == LyricsProviderLRCLIB {
|
||||||
|
time.Sleep(lyricsProviderPriorityGrace + 800*time.Millisecond)
|
||||||
|
return &LyricsResponse{Provider: "LRCLIB", PlainLyrics: "slow"}, nil, true
|
||||||
|
}
|
||||||
|
return &LyricsResponse{Provider: "Apple Music", PlainLyrics: "fast"}, nil, true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("concurrent providers returned error: %v", err)
|
||||||
|
}
|
||||||
|
if lyrics == nil || lyrics.Provider != "Apple Music" {
|
||||||
|
t.Fatalf("expected fast fallback lyrics, got %#v", lyrics)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed >= lyricsProviderPriorityGrace+700*time.Millisecond {
|
||||||
|
t.Fatalf("fallback waited too long: %s", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentLyricsProvidersPreferEarlierProviderWithinGrace(t *testing.T) {
|
||||||
|
clearLyricsProviderHealth()
|
||||||
|
defer clearLyricsProviderHealth()
|
||||||
|
|
||||||
|
lyrics, err := fetchBuiltInLyricsProviders(
|
||||||
|
[]string{LyricsProviderLRCLIB, LyricsProviderAppleMusic},
|
||||||
|
lyricsProviderSearchRequest{},
|
||||||
|
func(providerName string, _ lyricsProviderSearchRequest) (*LyricsResponse, error, bool) {
|
||||||
|
if providerName == LyricsProviderLRCLIB {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
return &LyricsResponse{Provider: "LRCLIB", PlainLyrics: "preferred"}, nil, true
|
||||||
|
}
|
||||||
|
return &LyricsResponse{Provider: "Apple Music", PlainLyrics: "fast"}, nil, true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("concurrent providers returned error: %v", err)
|
||||||
|
}
|
||||||
|
if lyrics == nil || lyrics.Provider != "LRCLIB" {
|
||||||
|
t.Fatalf("expected preferred provider lyrics, got %#v", lyrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
||||||
|
clearAppleMusicToken()
|
||||||
|
defer clearAppleMusicToken()
|
||||||
|
if len(lyricsPlusServers) == 0 || lyricsPlusServers[0] != "https://lyricsplus.prjktla.workers.dev" {
|
||||||
|
t.Fatalf("unexpected LyricsPlus server order = %#v", lyricsPlusServers)
|
||||||
|
}
|
||||||
|
|
||||||
paxJSON := `{"type":"Syllable","content":[{"timestamp":1000,"oppositeTurn":true,"background":true,"text":[{"text":"Hel","part":true,"timestamp":1000},{"text":"lo","part":false,"timestamp":1200,"endtime":1500}],"backgroundText":[{"text":"bg","part":false,"timestamp":900}]}]}`
|
paxJSON := `{"type":"Syllable","content":[{"timestamp":1000,"oppositeTurn":true,"background":true,"text":[{"text":"Hel","part":true,"timestamp":1000},{"text":"lo","part":false,"timestamp":1200,"endtime":1500}],"backgroundText":[{"text":"bg","part":false,"timestamp":900}]}]}`
|
||||||
apple := &AppleMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
apple := &AppleMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(req.URL.Path, "/apple-music/search"):
|
case req.URL.Host == "beta.music.apple.com" && (req.URL.Path == "" || req.URL.Path == "/"):
|
||||||
if req.URL.Query().Get("q") == "bad" {
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`<script src="/assets/index~test.js"></script>`)), Request: req}, nil
|
||||||
return &http.Response{StatusCode: 500, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`error`)), Request: req}, nil
|
case req.URL.Host == "beta.music.apple.com" && req.URL.Path == "/assets/index~test.js":
|
||||||
}
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`const token="eyJ0eXAiOiJKV1Q.eyJpc3MiOiJ0ZXN0.c2ln";`)), Request: req}, nil
|
||||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"id":"apple-2","songName":"Other","artistName":"Other","duration":1000},{"id":"apple-1","songName":"Song","artistName":"Artist","albumName":"Album","duration":180000}]`)), Request: req}, nil
|
case req.URL.Host == "amp-api.music.apple.com" && strings.Contains(req.URL.Path, "/v1/catalog/us/search"):
|
||||||
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"results":{"songs":{"data":[{"id":"apple-2"},{"id":"apple-1"}]}},"resources":{"songs":{"apple-2":{"attributes":{"name":"Other","artistName":"Other","durationInMillis":1000}},"apple-1":{"attributes":{"name":"Song","artistName":"Artist","albumName":"Album","durationInMillis":180000}}}}}`)), Request: req}, nil
|
||||||
case strings.Contains(req.URL.Path, "/apple-music/lyrics"):
|
case strings.Contains(req.URL.Path, "/apple-music/lyrics"):
|
||||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(paxJSON)), Request: req}, nil
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(paxJSON)), Request: req}, nil
|
||||||
default:
|
default:
|
||||||
@@ -177,6 +302,9 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
|||||||
if !strings.Contains(elrc, "<00:") {
|
if !strings.Contains(elrc, "<00:") {
|
||||||
t.Fatalf("elrc pax should include inline word timing: %q", elrc)
|
t.Fatalf("elrc pax should include inline word timing: %q", elrc)
|
||||||
}
|
}
|
||||||
|
if preferred, err := formatPaxLyricsToLRC(`{"elrcMultiPerson":"[00:01.00]v1:<00:01.00>Hello","content":[{"timestamp":1000,"text":[{"text":"Fallback","part":false}]}]}`, true, true); err != nil || !strings.Contains(preferred, "Hello") {
|
||||||
|
t.Fatalf("preferred apple elrc = %q/%v", preferred, err)
|
||||||
|
}
|
||||||
if _, err := apple.SearchSong("", "", 0); err == nil {
|
if _, err := apple.SearchSong("", "", 0); err == nil {
|
||||||
t.Fatal("expected empty apple search error")
|
t.Fatal("expected empty apple search error")
|
||||||
}
|
}
|
||||||
@@ -229,6 +357,12 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
|||||||
if _, err := netease.SearchSong("", ""); err == nil {
|
if _, err := netease.SearchSong("", ""); err == nil {
|
||||||
t.Fatal("expected empty netease search error")
|
t.Fatal("expected empty netease search error")
|
||||||
}
|
}
|
||||||
|
rateLimitedNetease := &NeteaseClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"msg":"操作频繁,请稍候再试","code":405,"message":"操作频繁,请稍候再试"}`)), Request: req}, nil
|
||||||
|
})}}
|
||||||
|
if _, err := rateLimitedNetease.SearchSong("Song", "Artist"); err == nil || !isLyricsProviderUnavailableError(err) {
|
||||||
|
t.Fatalf("expected unavailable netease rate-limit error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
qq := &QQMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
qq := &QQMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
if req.Method != http.MethodPost {
|
if req.Method != http.MethodPost {
|
||||||
@@ -304,6 +438,9 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
|||||||
genius := &GeniusLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
genius := &GeniusLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(req.URL.Path, "/api/search/multi"):
|
case strings.Contains(req.URL.Path, "/api/search/multi"):
|
||||||
|
if got := req.URL.Query().Get("per_page"); got != "5" {
|
||||||
|
t.Fatalf("genius per_page = %q", got)
|
||||||
|
}
|
||||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"response":{"sections":[{"hits":[{"type":"song","result":{"title":"Song","primary_artist_names":"Artist","url":"https://genius.com/artist-song-lyrics"}}]}]}}`)), Request: req}, nil
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"response":{"sections":[{"hits":[{"type":"song","result":{"title":"Song","primary_artist_names":"Artist","url":"https://genius.com/artist-song-lyrics"}}]}]}}`)), Request: req}, nil
|
||||||
case strings.Contains(req.URL.Path, "/genius/lyrics"):
|
case strings.Contains(req.URL.Path, "/genius/lyrics"):
|
||||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":false,"lyrics":"Genius line"}`)), Request: req}, nil
|
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":false,"lyrics":"Genius line"}`)), Request: req}, nil
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEditM4AFreeformTextWritesISRCAndLabel(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "track.m4a")
|
||||||
|
|
||||||
|
ilst := buildM4ATextTag("\xa9nam", "Title")
|
||||||
|
if err := os.WriteFile(path, buildM4AFileWithIlst(ilst, true), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EditM4AFreeformText(path, map[string]string{
|
||||||
|
"isrc": "USRC17607839",
|
||||||
|
"label": "Some Label",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("EditM4AFreeformText: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := ReadM4ATags(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadM4ATags: %v", err)
|
||||||
|
}
|
||||||
|
if meta.ISRC != "USRC17607839" {
|
||||||
|
t.Fatalf("ISRC = %q, want USRC17607839", meta.ISRC)
|
||||||
|
}
|
||||||
|
if meta.Label != "Some Label" {
|
||||||
|
t.Fatalf("Label = %q, want Some Label", meta.Label)
|
||||||
|
}
|
||||||
|
if meta.Title != "Title" {
|
||||||
|
t.Fatalf("Title = %q, want Title (existing tag must survive)", meta.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEditM4AFreeformTextReplacesExisting(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "track.m4a")
|
||||||
|
|
||||||
|
ilst := buildM4ATextTag("\xa9nam", "Title")
|
||||||
|
ilst = append(ilst, buildM4AFreeformAtom("ISRC", "OLDISRC00001")...)
|
||||||
|
ilst = append(ilst, buildM4AFreeformAtom("LABEL", "Old Label")...)
|
||||||
|
if err := os.WriteFile(path, buildM4AFileWithIlst(ilst, true), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := EditM4AFreeformText(path, map[string]string{
|
||||||
|
"isrc": "NEWISRC00002",
|
||||||
|
"label": "",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("EditM4AFreeformText: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := ReadM4ATags(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadM4ATags: %v", err)
|
||||||
|
}
|
||||||
|
if meta.ISRC != "NEWISRC00002" {
|
||||||
|
t.Fatalf("ISRC = %q, want NEWISRC00002", meta.ISRC)
|
||||||
|
}
|
||||||
|
if meta.Label != "" {
|
||||||
|
t.Fatalf("Label = %q, want empty (cleared)", meta.Label)
|
||||||
|
}
|
||||||
|
}
|
||||||
+213
-42
@@ -6,7 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
stdimage "image"
|
stdimage "image"
|
||||||
_ "image/gif"
|
_ "image/gif"
|
||||||
_ "image/jpeg"
|
"image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
@@ -71,11 +71,83 @@ func detectCoverMIME(coverPath string, coverData []byte) string {
|
|||||||
return "image/jpeg"
|
return "image/jpeg"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxFlacPictureBytes keeps cover art below the 24-bit length field of a FLAC
|
||||||
|
// metadata block; go-flac silently truncates oversized blocks into a corrupt file.
|
||||||
|
const maxFlacPictureBytes = 16 * 1000 * 1000
|
||||||
|
|
||||||
|
// fitCoverForFlac returns cover bytes that fit inside a FLAC PICTURE block,
|
||||||
|
// re-encoding and downscaling when needed. Returns false if the data cannot be
|
||||||
|
// decoded as an image.
|
||||||
|
func fitCoverForFlac(coverData []byte) ([]byte, bool) {
|
||||||
|
if len(coverData) <= maxFlacPictureBytes {
|
||||||
|
return coverData, true
|
||||||
|
}
|
||||||
|
|
||||||
|
img, _, err := stdimage.Decode(bytes.NewReader(coverData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, quality := range []int{90, 80, 70, 60} {
|
||||||
|
if encoded, ok := encodeJPEGUnder(img, quality, maxFlacPictureBytes); ok {
|
||||||
|
return encoded, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, maxDim := range []int{1500, 1200, 1000, 800} {
|
||||||
|
scaled := downscaleImage(img, maxDim)
|
||||||
|
if encoded, ok := encodeJPEGUnder(scaled, 85, maxFlacPictureBytes); ok {
|
||||||
|
return encoded, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeJPEGUnder(img stdimage.Image, quality, limit int) ([]byte, bool) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if buf.Len() > limit {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return buf.Bytes(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func downscaleImage(img stdimage.Image, maxDim int) stdimage.Image {
|
||||||
|
bounds := img.Bounds()
|
||||||
|
width, height := bounds.Dx(), bounds.Dy()
|
||||||
|
if width <= maxDim && height <= maxDim {
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
|
||||||
|
scale := float64(maxDim) / float64(max(width, height))
|
||||||
|
newWidth := max(1, int(float64(width)*scale))
|
||||||
|
newHeight := max(1, int(float64(height)*scale))
|
||||||
|
|
||||||
|
dst := stdimage.NewRGBA(stdimage.Rect(0, 0, newWidth, newHeight))
|
||||||
|
for y := 0; y < newHeight; y++ {
|
||||||
|
srcY := bounds.Min.Y + int(float64(y)/scale)
|
||||||
|
for x := 0; x < newWidth; x++ {
|
||||||
|
srcX := bounds.Min.X + int(float64(x)/scale)
|
||||||
|
dst.Set(x, y, img.At(srcX, srcY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock, error) {
|
func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock, error) {
|
||||||
if len(coverData) == 0 {
|
if len(coverData) == 0 {
|
||||||
return flac.MetaDataBlock{}, fmt.Errorf("empty cover data")
|
return flac.MetaDataBlock{}, fmt.Errorf("empty cover data")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fitted, ok := fitCoverForFlac(coverData)
|
||||||
|
if !ok {
|
||||||
|
return flac.MetaDataBlock{}, fmt.Errorf("cover too large for FLAC picture block and could not be resized")
|
||||||
|
}
|
||||||
|
coverData = fitted
|
||||||
|
|
||||||
mime := detectCoverMIME(coverPath, coverData)
|
mime := detectCoverMIME(coverPath, coverData)
|
||||||
picture := &flacpicture.MetadataBlockPicture{
|
picture := &flacpicture.MetadataBlockPicture{
|
||||||
PictureType: flacpicture.PictureTypeFrontCover,
|
PictureType: flacpicture.PictureTypeFrontCover,
|
||||||
@@ -175,10 +247,11 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
|
|
||||||
picBlock, err := buildPictureBlock(coverPath, coverData)
|
picBlock, err := buildPictureBlock(coverPath, coverData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create picture block: %w", err)
|
fmt.Printf("[Metadata] Warning: skipping cover art: %v\n", err)
|
||||||
|
} else {
|
||||||
|
f.Meta = append(f.Meta, &picBlock)
|
||||||
|
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
f.Meta = append(f.Meta, &picBlock)
|
|
||||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
|
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
|
||||||
@@ -230,10 +303,11 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
|
|
||||||
picBlock, err := buildPictureBlock("", coverData)
|
picBlock, err := buildPictureBlock("", coverData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create picture block: %w", err)
|
fmt.Printf("[Metadata] Warning: skipping cover art: %v\n", err)
|
||||||
|
} else {
|
||||||
|
f.Meta = append(f.Meta, &picBlock)
|
||||||
|
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
||||||
}
|
}
|
||||||
f.Meta = append(f.Meta, &picBlock)
|
|
||||||
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
@@ -906,6 +980,32 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
return extractLyricsFromSidecarLRC(filePath)
|
return extractLyricsFromSidecarLRC(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".wav") {
|
||||||
|
meta, err := ReadWAVTags(filePath)
|
||||||
|
if err == nil && meta != nil {
|
||||||
|
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||||
|
return meta.Lyrics, nil
|
||||||
|
}
|
||||||
|
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||||
|
return meta.Comment, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extractLyricsFromSidecarLRC(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc") {
|
||||||
|
meta, err := ReadAIFFTags(filePath)
|
||||||
|
if err == nil && meta != nil {
|
||||||
|
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||||
|
return meta.Lyrics, nil
|
||||||
|
}
|
||||||
|
if looksLikeEmbeddedLyrics(meta.Comment) {
|
||||||
|
return meta.Comment, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return extractLyricsFromSidecarLRC(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
return extractLyricsFromSidecarLRC(filePath)
|
return extractLyricsFromSidecarLRC(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1097,9 +1197,7 @@ func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
|
|||||||
udtaBodyStart := udta.offset + udta.headerSize
|
udtaBodyStart := udta.offset + udta.headerSize
|
||||||
udtaBodySize := udta.size - udta.headerSize
|
udtaBodySize := udta.size - udta.headerSize
|
||||||
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
|
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
|
||||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 {
|
||||||
metaBodySize := meta.size - meta.headerSize - 4
|
|
||||||
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
|
|
||||||
return ilst, nil
|
return ilst, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1107,9 +1205,7 @@ func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
|
|||||||
|
|
||||||
// Path 2: moov > meta > ilst (no udta wrapper)
|
// Path 2: moov > meta > ilst (no udta wrapper)
|
||||||
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
|
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
|
||||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 {
|
||||||
metaBodySize := meta.size - meta.headerSize - 4
|
|
||||||
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
|
|
||||||
return ilst, nil
|
return ilst, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1117,6 +1213,26 @@ func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
|
|||||||
return atomHeader{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
|
return atomHeader{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// findIlstInMeta locates the ilst atom inside a meta atom, handling both
|
||||||
|
// layouts: ISO-BMFF (4-byte version/flags before the child atoms, written by
|
||||||
|
// FFmpeg's mp4 muxer) and QuickTime (no version/flags, written by the mov muxer
|
||||||
|
// used for AC-4 passthrough).
|
||||||
|
func findIlstInMeta(f *os.File, meta atomHeader, fileSize int64) (atomHeader, bool) {
|
||||||
|
// ISO-BMFF: skip the 4-byte version/flags that precede the child atoms.
|
||||||
|
isoStart := meta.offset + meta.headerSize + 4
|
||||||
|
isoSize := meta.size - meta.headerSize - 4
|
||||||
|
if ilst, ok, _ := findAtomInRange(f, isoStart, isoSize, "ilst", fileSize); ok {
|
||||||
|
return ilst, true
|
||||||
|
}
|
||||||
|
// QuickTime: child atoms begin immediately after the meta header.
|
||||||
|
qtStart := meta.offset + meta.headerSize
|
||||||
|
qtSize := meta.size - meta.headerSize
|
||||||
|
if ilst, ok, _ := findAtomInRange(f, qtStart, qtSize, "ilst", fileSize); ok {
|
||||||
|
return ilst, true
|
||||||
|
}
|
||||||
|
return atomHeader{}, false
|
||||||
|
}
|
||||||
|
|
||||||
func readM4ADataAtomPayload(f *os.File, dataAtom atomHeader) ([]byte, error) {
|
func readM4ADataAtomPayload(f *os.File, dataAtom atomHeader) ([]byte, error) {
|
||||||
payloadStart := dataAtom.offset + dataAtom.headerSize + 8
|
payloadStart := dataAtom.offset + dataAtom.headerSize + 8
|
||||||
payloadLen := dataAtom.size - dataAtom.headerSize - 8
|
payloadLen := dataAtom.size - dataAtom.headerSize - 8
|
||||||
@@ -1254,9 +1370,7 @@ func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) {
|
|||||||
udtaBodyStart := udta.offset + udta.headerSize
|
udtaBodyStart := udta.offset + udta.headerSize
|
||||||
udtaBodySize := udta.size - udta.headerSize
|
udtaBodySize := udta.size - udta.headerSize
|
||||||
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
|
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
|
||||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 {
|
||||||
metaBodySize := meta.size - meta.headerSize - 4
|
|
||||||
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
|
|
||||||
udtaCopy := udta
|
udtaCopy := udta
|
||||||
return m4aMetadataPath{
|
return m4aMetadataPath{
|
||||||
moov: moov,
|
moov: moov,
|
||||||
@@ -1269,9 +1383,7 @@ func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
|
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
|
||||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 {
|
||||||
metaBodySize := meta.size - meta.headerSize - 4
|
|
||||||
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
|
|
||||||
return m4aMetadataPath{
|
return m4aMetadataPath{
|
||||||
moov: moov,
|
moov: moov,
|
||||||
meta: meta,
|
meta: meta,
|
||||||
@@ -1406,6 +1518,51 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remove := map[string]struct{}{
|
||||||
|
"REPLAYGAIN_TRACK_GAIN": {},
|
||||||
|
"REPLAYGAIN_TRACK_PEAK": {},
|
||||||
|
"REPLAYGAIN_ALBUM_GAIN": {},
|
||||||
|
"REPLAYGAIN_ALBUM_PEAK": {},
|
||||||
|
"ITUNNORM": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
order := []string{
|
||||||
|
"replaygain_track_gain",
|
||||||
|
"replaygain_track_peak",
|
||||||
|
"replaygain_album_gain",
|
||||||
|
"replaygain_album_peak",
|
||||||
|
"iTunNORM",
|
||||||
|
}
|
||||||
|
tags := make([]m4aFreeformTag, 0, len(order))
|
||||||
|
for _, key := range order {
|
||||||
|
value := strings.TrimSpace(replayGainFields[key])
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := key
|
||||||
|
if key != "iTunNORM" {
|
||||||
|
name = strings.ToLower(key)
|
||||||
|
}
|
||||||
|
tags = append(tags, m4aFreeformTag{name: name, value: value})
|
||||||
|
}
|
||||||
|
|
||||||
|
return writeM4AFreeformTags(filePath, remove, tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
type m4aFreeformTag struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeM4AFreeformTags rewrites the ilst atom in place: it drops every existing
|
||||||
|
// freeform ("----") atom whose uppercased name is in `remove`, then appends the
|
||||||
|
// supplied tags (empty values are skipped, which effectively clears the field).
|
||||||
|
// Atom sizes are fixed up along the ilst -> meta -> udta -> moov chain.
|
||||||
|
//
|
||||||
|
// FFmpeg's MP4 muxer only writes a fixed set of recognized keys to the ilst, so
|
||||||
|
// fields like ISRC and LABEL are silently dropped when written via -metadata.
|
||||||
|
// Writing them as iTunes freeform atoms natively is the only way they persist.
|
||||||
|
func writeM4AFreeformTags(filePath string, remove map[string]struct{}, tags []m4aFreeformTag) error {
|
||||||
f, err := os.Open(filePath)
|
f, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1419,6 +1576,13 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
|||||||
|
|
||||||
path, err := findM4AMetadataPath(f, info.Size())
|
path, err := findM4AMetadataPath(f, info.Size())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// MOV-style containers (e.g. AC-4 passthrough) store tags as QuickTime
|
||||||
|
// atoms under udta with no iTunes meta>ilst structure. There is nowhere
|
||||||
|
// to write freeform tags, so skip gracefully instead of failing.
|
||||||
|
if strings.Contains(err.Error(), "ilst not found") {
|
||||||
|
GoLog("[Metadata] No iTunes ilst container; skipping freeform tags")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1430,13 +1594,6 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
|||||||
bodyStart := path.ilst.offset + path.ilst.headerSize
|
bodyStart := path.ilst.offset + path.ilst.headerSize
|
||||||
bodyEnd := path.ilst.offset + path.ilst.size
|
bodyEnd := path.ilst.offset + path.ilst.size
|
||||||
newBody := make([]byte, 0, int(path.ilst.size))
|
newBody := make([]byte, 0, int(path.ilst.size))
|
||||||
targets := map[string]struct{}{
|
|
||||||
"REPLAYGAIN_TRACK_GAIN": {},
|
|
||||||
"REPLAYGAIN_TRACK_PEAK": {},
|
|
||||||
"REPLAYGAIN_ALBUM_GAIN": {},
|
|
||||||
"REPLAYGAIN_ALBUM_PEAK": {},
|
|
||||||
"ITUNNORM": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
for pos := bodyStart; pos+8 <= bodyEnd; {
|
for pos := bodyStart; pos+8 <= bodyEnd; {
|
||||||
header, readErr := readAtomHeaderAt(f, pos, info.Size())
|
header, readErr := readAtomHeaderAt(f, pos, info.Size())
|
||||||
@@ -1454,7 +1611,7 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
|||||||
if header.typ == "----" {
|
if header.typ == "----" {
|
||||||
name, _, freeformErr := readM4AFreeformValue(f, header, info.Size())
|
name, _, freeformErr := readM4AFreeformValue(f, header, info.Size())
|
||||||
if freeformErr == nil {
|
if freeformErr == nil {
|
||||||
if _, ok := targets[strings.ToUpper(strings.TrimSpace(name))]; ok {
|
if _, ok := remove[strings.ToUpper(strings.TrimSpace(name))]; ok {
|
||||||
keep = false
|
keep = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1466,23 +1623,11 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
|||||||
pos += header.size
|
pos += header.size
|
||||||
}
|
}
|
||||||
|
|
||||||
order := []string{
|
for _, tag := range tags {
|
||||||
"replaygain_track_gain",
|
if strings.TrimSpace(tag.value) == "" {
|
||||||
"replaygain_track_peak",
|
|
||||||
"replaygain_album_gain",
|
|
||||||
"replaygain_album_peak",
|
|
||||||
"iTunNORM",
|
|
||||||
}
|
|
||||||
for _, key := range order {
|
|
||||||
value := strings.TrimSpace(replayGainFields[key])
|
|
||||||
if value == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
name := key
|
newBody = append(newBody, buildM4AFreeformAtom(tag.name, tag.value)...)
|
||||||
if key != "iTunNORM" {
|
|
||||||
name = strings.ToLower(key)
|
|
||||||
}
|
|
||||||
newBody = append(newBody, buildM4AFreeformAtom(name, value)...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
newIlst := buildM4AAtom("ilst", newBody)
|
newIlst := buildM4AAtom("ilst", newBody)
|
||||||
@@ -1509,6 +1654,32 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
|||||||
return os.WriteFile(filePath, updated, 0o644)
|
return os.WriteFile(filePath, updated, 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EditM4AFreeformText writes ISRC and label tags into an M4A/MP4 file as iTunes
|
||||||
|
// freeform atoms. These keys are not part of FFmpeg's MP4 metadata key set, so
|
||||||
|
// they must be written natively for the values to actually persist. An empty
|
||||||
|
// value clears the corresponding tag. Other (recognized) tags are left intact.
|
||||||
|
func EditM4AFreeformText(filePath string, fields map[string]string) error {
|
||||||
|
_, hasISRC := fields["isrc"]
|
||||||
|
_, hasLabel := fields["label"]
|
||||||
|
if !hasISRC && !hasLabel {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
remove := map[string]struct{}{}
|
||||||
|
tags := make([]m4aFreeformTag, 0, 2)
|
||||||
|
if hasISRC {
|
||||||
|
remove["ISRC"] = struct{}{}
|
||||||
|
tags = append(tags, m4aFreeformTag{name: "ISRC", value: strings.TrimSpace(fields["isrc"])})
|
||||||
|
}
|
||||||
|
if hasLabel {
|
||||||
|
remove["LABEL"] = struct{}{}
|
||||||
|
remove["ORGANIZATION"] = struct{}{}
|
||||||
|
tags = append(tags, m4aFreeformTag{name: "LABEL", value: strings.TrimSpace(fields["label"])})
|
||||||
|
}
|
||||||
|
|
||||||
|
return writeM4AFreeformTags(filePath, remove, tags)
|
||||||
|
}
|
||||||
|
|
||||||
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
|
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
|
||||||
ext := filepath.Ext(filePath)
|
ext := filepath.Ext(filePath)
|
||||||
base := strings.TrimSuffix(filePath, ext)
|
base := strings.TrimSuffix(filePath, ext)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return file, nil
|
return file, nil
|
||||||
}
|
}
|
||||||
if strings.Contains(strings.ToLower(err.Error()), "permission denied") {
|
if os.IsPermission(err) {
|
||||||
return os.OpenFile(path, os.O_WRONLY, 0)
|
return os.OpenFile(path, os.O_WRONLY, 0)
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const isrgRootX1PEM = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
|
||||||
|
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||||
|
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
|
||||||
|
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
|
||||||
|
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
|
||||||
|
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
|
||||||
|
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
|
||||||
|
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
|
||||||
|
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
|
||||||
|
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
|
||||||
|
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
|
||||||
|
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
|
||||||
|
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
|
||||||
|
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
|
||||||
|
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
|
||||||
|
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
|
||||||
|
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
|
||||||
|
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
|
||||||
|
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
|
||||||
|
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
|
||||||
|
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
|
||||||
|
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
|
||||||
|
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
|
||||||
|
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
|
||||||
|
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
|
||||||
|
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
|
||||||
|
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
|
||||||
|
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
|
||||||
|
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
|
||||||
|
-----END CERTIFICATE-----`
|
||||||
|
|
||||||
|
const isrgRootX2PEM = `-----BEGIN CERTIFICATE-----
|
||||||
|
MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw
|
||||||
|
CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg
|
||||||
|
R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00
|
||||||
|
MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT
|
||||||
|
ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw
|
||||||
|
EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW
|
||||||
|
+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9
|
||||||
|
ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T
|
||||||
|
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI
|
||||||
|
zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW
|
||||||
|
tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
|
||||||
|
/q4AaOeMSQ+2b1tbFfLn
|
||||||
|
-----END CERTIFICATE-----`
|
||||||
|
|
||||||
|
var (
|
||||||
|
supplementalRootCAsOnce sync.Once
|
||||||
|
supplementalRootCAsPool *x509.CertPool
|
||||||
|
)
|
||||||
|
|
||||||
|
func supplementalRootCAs() *x509.CertPool {
|
||||||
|
supplementalRootCAsOnce.Do(func() {
|
||||||
|
pool, err := x509.SystemCertPool()
|
||||||
|
if err != nil || pool == nil {
|
||||||
|
pool = x509.NewCertPool()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pem := range []string{isrgRootX1PEM, isrgRootX2PEM} {
|
||||||
|
pool.AppendCertsFromPEM([]byte(pem))
|
||||||
|
}
|
||||||
|
supplementalRootCAsPool = pool
|
||||||
|
})
|
||||||
|
|
||||||
|
return supplementalRootCAsPool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTLSCompatibilityConfig(insecureTLS bool) *tls.Config {
|
||||||
|
return &tls.Config{
|
||||||
|
RootCAs: supplementalRootCAs(),
|
||||||
|
InsecureSkipVerify: insecureTLS,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,959 @@
|
|||||||
|
package gobackend
|
||||||
|
|
||||||
|
// WAV (RIFF) and AIFF/AIFC support: quality probing, tag reading/writing, and
|
||||||
|
// cover-art extraction. These containers are not handled by go-flac, so chunks
|
||||||
|
// are parsed/written by hand here.
|
||||||
|
//
|
||||||
|
// Tags are stored as an embedded ID3v2.4 tag (UTF-8): WAV uses a lowercase
|
||||||
|
// "id3 " chunk, AIFF uses an uppercase "ID3 " chunk. ID3v2.4 is chosen because
|
||||||
|
// the existing ID3 reader (parseID3v23Frames with version=4) reads synchsafe
|
||||||
|
// frame sizes and UTF-8 text, so anything we write is read back losslessly.
|
||||||
|
//
|
||||||
|
// Reading also recognises a WAV "LIST"/"INFO" block as a fallback for files
|
||||||
|
// that carry only RIFF INFO tags (common from other taggers).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WAVQuality / AIFFQuality mirror the other GetXQuality result shapes.
|
||||||
|
type WAVQuality struct {
|
||||||
|
SampleRate int
|
||||||
|
BitDepth int
|
||||||
|
Channels int
|
||||||
|
Duration int
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
wavMaxMetaChunk = 16 * 1024 * 1024 // safety cap for buffering a metadata chunk
|
||||||
|
id3ChunkWAV = "id3 "
|
||||||
|
id3ChunkAIFF = "ID3 "
|
||||||
|
wavFormatPCM = 0x0001
|
||||||
|
wavFormatFloat = 0x0003
|
||||||
|
wavFormatExtensn = 0xFFFE
|
||||||
|
)
|
||||||
|
|
||||||
|
func putUint32(dst []byte, le bool, v uint32) {
|
||||||
|
if le {
|
||||||
|
binary.LittleEndian.PutUint32(dst, v)
|
||||||
|
} else {
|
||||||
|
binary.BigEndian.PutUint32(dst, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readUint32(b []byte, le bool) uint32 {
|
||||||
|
if le {
|
||||||
|
return binary.LittleEndian.Uint32(b)
|
||||||
|
}
|
||||||
|
return binary.BigEndian.Uint32(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func synchsafeEncode(n int) []byte {
|
||||||
|
return []byte{
|
||||||
|
byte((n >> 21) & 0x7f),
|
||||||
|
byte((n >> 14) & 0x7f),
|
||||||
|
byte((n >> 7) & 0x7f),
|
||||||
|
byte(n & 0x7f),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func synchsafeDecode(b []byte) int {
|
||||||
|
if len(b) < 4 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int(b[0])<<21 | int(b[1])<<14 | int(b[2])<<7 | int(b[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseExtendedFloat80 decodes an 80-bit IEEE 754 extended float (used by the
|
||||||
|
// AIFF COMM chunk for the sample rate).
|
||||||
|
func parseExtendedFloat80(b []byte) float64 {
|
||||||
|
if len(b) < 10 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
sign := 1.0
|
||||||
|
if b[0]&0x80 != 0 {
|
||||||
|
sign = -1.0
|
||||||
|
}
|
||||||
|
exponent := int(b[0]&0x7f)<<8 | int(b[1])
|
||||||
|
var mantissa uint64
|
||||||
|
for i := 2; i < 10; i++ {
|
||||||
|
mantissa = mantissa<<8 | uint64(b[i])
|
||||||
|
}
|
||||||
|
if exponent == 0 && mantissa == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return sign * float64(mantissa) * math.Pow(2, float64(exponent-16383-63))
|
||||||
|
}
|
||||||
|
|
||||||
|
type wavProbe struct {
|
||||||
|
sampleRate int
|
||||||
|
bitDepth int
|
||||||
|
channels int
|
||||||
|
byteRate int
|
||||||
|
dataSize int64
|
||||||
|
id3 []byte
|
||||||
|
info map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamProbeWAV walks the top-level RIFF chunks, buffering only the small
|
||||||
|
// metadata chunks (fmt/id3/LIST) and skipping the large data chunk.
|
||||||
|
func streamProbeWAV(f *os.File) (*wavProbe, error) {
|
||||||
|
header := make([]byte, 12)
|
||||||
|
if _, err := io.ReadFull(f, header); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" {
|
||||||
|
return nil, fmt.Errorf("not a WAVE file")
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &wavProbe{info: map[string]string{}}
|
||||||
|
hdr := make([]byte, 8)
|
||||||
|
for {
|
||||||
|
if _, err := io.ReadFull(f, hdr); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
id := string(hdr[0:4])
|
||||||
|
size := readUint32(hdr[4:8], true)
|
||||||
|
pad := int64(size) & 1
|
||||||
|
|
||||||
|
switch id {
|
||||||
|
case "fmt ":
|
||||||
|
buf := make([]byte, size)
|
||||||
|
if _, err := io.ReadFull(f, buf); err != nil {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
if len(buf) >= 16 {
|
||||||
|
format := binary.LittleEndian.Uint16(buf[0:2])
|
||||||
|
p.channels = int(binary.LittleEndian.Uint16(buf[2:4]))
|
||||||
|
p.sampleRate = int(binary.LittleEndian.Uint32(buf[4:8]))
|
||||||
|
p.byteRate = int(binary.LittleEndian.Uint32(buf[8:12]))
|
||||||
|
p.bitDepth = int(binary.LittleEndian.Uint16(buf[14:16]))
|
||||||
|
if format == wavFormatExtensn && len(buf) >= 26 {
|
||||||
|
// Valid bits per sample lives in the extension; the real
|
||||||
|
// PCM format tag is in the GUID, but bitDepth from the
|
||||||
|
// container field is sufficient for display.
|
||||||
|
if vb := int(binary.LittleEndian.Uint16(buf[18:20])); vb > 0 {
|
||||||
|
p.bitDepth = vb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pad == 1 {
|
||||||
|
f.Seek(pad, io.SeekCurrent)
|
||||||
|
}
|
||||||
|
case "data":
|
||||||
|
p.dataSize = int64(size)
|
||||||
|
f.Seek(int64(size)+pad, io.SeekCurrent)
|
||||||
|
case id3ChunkWAV, "ID3 ":
|
||||||
|
if size > 0 && size <= wavMaxMetaChunk {
|
||||||
|
buf := make([]byte, size)
|
||||||
|
if _, err := io.ReadFull(f, buf); err == nil {
|
||||||
|
p.id3 = buf
|
||||||
|
}
|
||||||
|
if pad == 1 {
|
||||||
|
f.Seek(pad, io.SeekCurrent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
f.Seek(int64(size)+pad, io.SeekCurrent)
|
||||||
|
}
|
||||||
|
case "LIST":
|
||||||
|
if size > 0 && size <= wavMaxMetaChunk {
|
||||||
|
buf := make([]byte, size)
|
||||||
|
if _, err := io.ReadFull(f, buf); err == nil {
|
||||||
|
parseRIFFInfo(buf, p.info)
|
||||||
|
}
|
||||||
|
if pad == 1 {
|
||||||
|
f.Seek(pad, io.SeekCurrent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
f.Seek(int64(size)+pad, io.SeekCurrent)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
f.Seek(int64(size)+pad, io.SeekCurrent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRIFFInfo reads a LIST/INFO block ("INFO" + sub-chunks like INAM, IART).
|
||||||
|
func parseRIFFInfo(buf []byte, out map[string]string) {
|
||||||
|
if len(buf) < 4 || string(buf[0:4]) != "INFO" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pos := 4
|
||||||
|
for pos+8 <= len(buf) {
|
||||||
|
id := string(buf[pos : pos+4])
|
||||||
|
size := int(binary.LittleEndian.Uint32(buf[pos+4 : pos+8]))
|
||||||
|
pos += 8
|
||||||
|
if size <= 0 || pos+size > len(buf) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
val := strings.TrimRight(string(buf[pos:pos+size]), "\x00")
|
||||||
|
out[id] = strings.TrimSpace(val)
|
||||||
|
pos += size
|
||||||
|
if size&1 == 1 {
|
||||||
|
pos++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func wavMetadataFromProbe(p *wavProbe) *AudioMetadata {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(p.id3) > 0 {
|
||||||
|
if meta, err := readID3v2FromBytes(p.id3); err == nil && meta != nil &&
|
||||||
|
(meta.Title != "" || meta.Artist != "" || meta.Album != "") {
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(p.info) > 0 {
|
||||||
|
meta := &AudioMetadata{
|
||||||
|
Title: p.info["INAM"],
|
||||||
|
Artist: p.info["IART"],
|
||||||
|
Album: p.info["IPRD"],
|
||||||
|
Genre: cleanGenre(p.info["IGNR"]),
|
||||||
|
Date: p.info["ICRD"],
|
||||||
|
Comment: p.info["ICMT"],
|
||||||
|
Copyright: p.info["ICOP"],
|
||||||
|
Composer: p.info["IMUS"],
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(p.info["ITRK"])); err == nil {
|
||||||
|
meta.TrackNumber = n
|
||||||
|
}
|
||||||
|
if meta.Date != "" && len(meta.Date) >= 4 {
|
||||||
|
meta.Year = meta.Date[:4]
|
||||||
|
}
|
||||||
|
if meta.Title != "" || meta.Artist != "" || meta.Album != "" {
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWAVQuality probes PCM parameters and computes duration from the data size.
|
||||||
|
func GetWAVQuality(filePath string) (*WAVQuality, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
p, err := streamProbeWAV(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
q := &WAVQuality{
|
||||||
|
SampleRate: p.sampleRate,
|
||||||
|
BitDepth: p.bitDepth,
|
||||||
|
Channels: p.channels,
|
||||||
|
}
|
||||||
|
if p.byteRate > 0 && p.dataSize > 0 {
|
||||||
|
q.Duration = int(p.dataSize / int64(p.byteRate))
|
||||||
|
} else if p.sampleRate > 0 && p.channels > 0 && p.bitDepth > 0 && p.dataSize > 0 {
|
||||||
|
bytesPerSec := int64(p.sampleRate * p.channels * p.bitDepth / 8)
|
||||||
|
if bytesPerSec > 0 {
|
||||||
|
q.Duration = int(p.dataSize / bytesPerSec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return q, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadWAVTags reads tags from a WAV file (ID3 chunk preferred, RIFF INFO fallback).
|
||||||
|
func ReadWAVTags(filePath string) (*AudioMetadata, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
p, err := streamProbeWAV(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
meta := wavMetadataFromProbe(p)
|
||||||
|
if meta == nil {
|
||||||
|
return nil, fmt.Errorf("no WAV tags found")
|
||||||
|
}
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type aiffProbe struct {
|
||||||
|
sampleRate int
|
||||||
|
bitDepth int
|
||||||
|
channels int
|
||||||
|
numFrames int64
|
||||||
|
id3 []byte
|
||||||
|
nameChunk string
|
||||||
|
authChunk string
|
||||||
|
annoChunk string
|
||||||
|
copyrightChunk string
|
||||||
|
}
|
||||||
|
|
||||||
|
func streamProbeAIFF(f *os.File) (*aiffProbe, error) {
|
||||||
|
header := make([]byte, 12)
|
||||||
|
if _, err := io.ReadFull(f, header); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
form := string(header[8:12])
|
||||||
|
if string(header[0:4]) != "FORM" || (form != "AIFF" && form != "AIFC") {
|
||||||
|
return nil, fmt.Errorf("not an AIFF file")
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &aiffProbe{}
|
||||||
|
hdr := make([]byte, 8)
|
||||||
|
for {
|
||||||
|
if _, err := io.ReadFull(f, hdr); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
id := string(hdr[0:4])
|
||||||
|
size := readUint32(hdr[4:8], false)
|
||||||
|
pad := int64(size) & 1
|
||||||
|
|
||||||
|
switch id {
|
||||||
|
case "COMM":
|
||||||
|
buf := make([]byte, size)
|
||||||
|
if _, err := io.ReadFull(f, buf); err != nil {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
if len(buf) >= 18 {
|
||||||
|
p.channels = int(binary.BigEndian.Uint16(buf[0:2]))
|
||||||
|
p.numFrames = int64(binary.BigEndian.Uint32(buf[2:6]))
|
||||||
|
p.bitDepth = int(binary.BigEndian.Uint16(buf[6:8]))
|
||||||
|
p.sampleRate = int(parseExtendedFloat80(buf[8:18]) + 0.5)
|
||||||
|
}
|
||||||
|
if pad == 1 {
|
||||||
|
f.Seek(pad, io.SeekCurrent)
|
||||||
|
}
|
||||||
|
case id3ChunkAIFF, "id3 ":
|
||||||
|
if size > 0 && size <= wavMaxMetaChunk {
|
||||||
|
buf := make([]byte, size)
|
||||||
|
if _, err := io.ReadFull(f, buf); err == nil {
|
||||||
|
p.id3 = buf
|
||||||
|
}
|
||||||
|
if pad == 1 {
|
||||||
|
f.Seek(pad, io.SeekCurrent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
f.Seek(int64(size)+pad, io.SeekCurrent)
|
||||||
|
}
|
||||||
|
case "NAME", "AUTH", "ANNO", "(c) ":
|
||||||
|
if size > 0 && size <= wavMaxMetaChunk {
|
||||||
|
buf := make([]byte, size)
|
||||||
|
if _, err := io.ReadFull(f, buf); err == nil {
|
||||||
|
val := strings.TrimRight(strings.TrimSpace(string(buf)), "\x00")
|
||||||
|
switch id {
|
||||||
|
case "NAME":
|
||||||
|
p.nameChunk = val
|
||||||
|
case "AUTH":
|
||||||
|
p.authChunk = val
|
||||||
|
case "ANNO":
|
||||||
|
p.annoChunk = val
|
||||||
|
case "(c) ":
|
||||||
|
p.copyrightChunk = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pad == 1 {
|
||||||
|
f.Seek(pad, io.SeekCurrent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
f.Seek(int64(size)+pad, io.SeekCurrent)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
f.Seek(int64(size)+pad, io.SeekCurrent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func aiffMetadataFromProbe(p *aiffProbe) *AudioMetadata {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(p.id3) > 0 {
|
||||||
|
if meta, err := readID3v2FromBytes(p.id3); err == nil && meta != nil &&
|
||||||
|
(meta.Title != "" || meta.Artist != "" || meta.Album != "") {
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.nameChunk != "" || p.authChunk != "" {
|
||||||
|
meta := &AudioMetadata{
|
||||||
|
Title: p.nameChunk,
|
||||||
|
Artist: p.authChunk,
|
||||||
|
Comment: p.annoChunk,
|
||||||
|
Copyright: p.copyrightChunk,
|
||||||
|
}
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAIFFQuality probes PCM parameters and computes duration from frame count.
|
||||||
|
func GetAIFFQuality(filePath string) (*WAVQuality, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
p, err := streamProbeAIFF(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
q := &WAVQuality{
|
||||||
|
SampleRate: p.sampleRate,
|
||||||
|
BitDepth: p.bitDepth,
|
||||||
|
Channels: p.channels,
|
||||||
|
}
|
||||||
|
if p.sampleRate > 0 && p.numFrames > 0 {
|
||||||
|
q.Duration = int(p.numFrames / int64(p.sampleRate))
|
||||||
|
}
|
||||||
|
return q, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadAIFFTags reads tags from an AIFF file (ID3 chunk preferred, AIFF text chunks fallback).
|
||||||
|
func ReadAIFFTags(filePath string) (*AudioMetadata, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
p, err := streamProbeAIFF(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
meta := aiffMetadataFromProbe(p)
|
||||||
|
if meta == nil {
|
||||||
|
return nil, fmt.Errorf("no AIFF tags found")
|
||||||
|
}
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readID3v2FromBytes parses an in-memory ID3v2 tag (the contents of a WAV "id3 "
|
||||||
|
// or AIFF "ID3 " chunk) by reusing the existing frame parsers.
|
||||||
|
func readID3v2FromBytes(data []byte) (*AudioMetadata, error) {
|
||||||
|
if len(data) < 10 || string(data[0:3]) != "ID3" {
|
||||||
|
return nil, fmt.Errorf("no ID3v2 header")
|
||||||
|
}
|
||||||
|
majorVersion := data[3]
|
||||||
|
flags := data[5]
|
||||||
|
unsync := (flags & 0x80) != 0
|
||||||
|
extendedHeader := (flags & 0x40) != 0
|
||||||
|
footerPresent := (flags & 0x10) != 0
|
||||||
|
|
||||||
|
size := synchsafeDecode(data[6:10])
|
||||||
|
if size <= 0 || 10+size > len(data) {
|
||||||
|
size = len(data) - 10
|
||||||
|
}
|
||||||
|
tagData := data[10 : 10+size]
|
||||||
|
|
||||||
|
if footerPresent && len(tagData) >= 10 {
|
||||||
|
footerStart := len(tagData) - 10
|
||||||
|
if footerStart >= 0 && string(tagData[footerStart:footerStart+3]) == "3DI" {
|
||||||
|
tagData = tagData[:footerStart]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if extendedHeader {
|
||||||
|
if skip := extendedHeaderSize(tagData, majorVersion); skip > 0 && skip < len(tagData) {
|
||||||
|
tagData = tagData[skip:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := &AudioMetadata{}
|
||||||
|
if majorVersion == 2 {
|
||||||
|
parseID3v22Frames(tagData, metadata, unsync)
|
||||||
|
} else {
|
||||||
|
parseID3v23Frames(tagData, metadata, majorVersion, unsync)
|
||||||
|
}
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractAPICFromID3 returns the first embedded picture (APIC/PIC) and its MIME.
|
||||||
|
func extractAPICFromID3(tag []byte) ([]byte, string) {
|
||||||
|
if len(tag) < 10 || string(tag[0:3]) != "ID3" {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
ver := tag[3]
|
||||||
|
size := synchsafeDecode(tag[6:10])
|
||||||
|
if size <= 0 || 10+size > len(tag) {
|
||||||
|
size = len(tag) - 10
|
||||||
|
}
|
||||||
|
data := tag[10 : 10+size]
|
||||||
|
|
||||||
|
pos := 0
|
||||||
|
for {
|
||||||
|
if ver == 2 {
|
||||||
|
if pos+6 > len(data) || data[pos] == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
id := string(data[pos : pos+3])
|
||||||
|
fsz := int(data[pos+3])<<16 | int(data[pos+4])<<8 | int(data[pos+5])
|
||||||
|
if fsz <= 0 || pos+6+fsz > len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if id == "PIC" {
|
||||||
|
return parseAPICFrame(data[pos+6:pos+6+fsz], ver)
|
||||||
|
}
|
||||||
|
pos += 6 + fsz
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if pos+10 > len(data) || data[pos] == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
id := string(data[pos : pos+4])
|
||||||
|
var fsz int
|
||||||
|
if ver == 4 {
|
||||||
|
fsz = synchsafeDecode(data[pos+4 : pos+8])
|
||||||
|
} else {
|
||||||
|
fsz = int(binary.BigEndian.Uint32(data[pos+4 : pos+8]))
|
||||||
|
}
|
||||||
|
if fsz <= 0 || pos+10+fsz > len(data) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if id == "APIC" {
|
||||||
|
return parseAPICFrame(data[pos+10:pos+10+fsz], ver)
|
||||||
|
}
|
||||||
|
pos += 10 + fsz
|
||||||
|
}
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildID3v24Tag builds a UTF-8 ID3v2.4 tag from metadata plus optional cover.
|
||||||
|
func buildID3v24Tag(meta *AudioMetadata, coverData []byte, coverMIME string) []byte {
|
||||||
|
var frames bytes.Buffer
|
||||||
|
|
||||||
|
writeFrame := func(id string, payload []byte) {
|
||||||
|
frames.WriteString(id)
|
||||||
|
frames.Write(synchsafeEncode(len(payload)))
|
||||||
|
frames.Write([]byte{0, 0})
|
||||||
|
frames.Write(payload)
|
||||||
|
}
|
||||||
|
writeText := func(id, val string) {
|
||||||
|
if strings.TrimSpace(val) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload := append([]byte{0x03}, []byte(val)...)
|
||||||
|
writeFrame(id, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeText("TIT2", meta.Title)
|
||||||
|
writeText("TPE1", meta.Artist)
|
||||||
|
writeText("TALB", meta.Album)
|
||||||
|
writeText("TPE2", meta.AlbumArtist)
|
||||||
|
writeText("TCON", meta.Genre)
|
||||||
|
writeText("TCOM", meta.Composer)
|
||||||
|
writeText("TPUB", meta.Label)
|
||||||
|
writeText("TCOP", meta.Copyright)
|
||||||
|
writeText("TSRC", meta.ISRC)
|
||||||
|
|
||||||
|
date := meta.Date
|
||||||
|
if date == "" {
|
||||||
|
date = meta.Year
|
||||||
|
}
|
||||||
|
writeText("TDRC", date)
|
||||||
|
|
||||||
|
if meta.TrackNumber > 0 {
|
||||||
|
if meta.TotalTracks > 0 {
|
||||||
|
writeText("TRCK", fmt.Sprintf("%d/%d", meta.TrackNumber, meta.TotalTracks))
|
||||||
|
} else {
|
||||||
|
writeText("TRCK", strconv.Itoa(meta.TrackNumber))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if meta.DiscNumber > 0 {
|
||||||
|
if meta.TotalDiscs > 0 {
|
||||||
|
writeText("TPOS", fmt.Sprintf("%d/%d", meta.DiscNumber, meta.TotalDiscs))
|
||||||
|
} else {
|
||||||
|
writeText("TPOS", strconv.Itoa(meta.DiscNumber))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(meta.Comment) != "" {
|
||||||
|
// COMM: encoding + language(3) + short desc(null) + text
|
||||||
|
payload := []byte{0x03}
|
||||||
|
payload = append(payload, []byte("eng")...)
|
||||||
|
payload = append(payload, 0x00) // empty description
|
||||||
|
payload = append(payload, []byte(meta.Comment)...)
|
||||||
|
writeFrame("COMM", payload)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(meta.Lyrics) != "" {
|
||||||
|
payload := []byte{0x03}
|
||||||
|
payload = append(payload, []byte("eng")...)
|
||||||
|
payload = append(payload, 0x00)
|
||||||
|
payload = append(payload, []byte(meta.Lyrics)...)
|
||||||
|
writeFrame("USLT", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplayGain as TXXX (description\0value), UTF-8.
|
||||||
|
writeTXXX := func(desc, val string) {
|
||||||
|
if strings.TrimSpace(val) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload := []byte{0x03}
|
||||||
|
payload = append(payload, []byte(desc)...)
|
||||||
|
payload = append(payload, 0x00)
|
||||||
|
payload = append(payload, []byte(val)...)
|
||||||
|
writeFrame("TXXX", payload)
|
||||||
|
}
|
||||||
|
writeTXXX("REPLAYGAIN_TRACK_GAIN", meta.ReplayGainTrackGain)
|
||||||
|
writeTXXX("REPLAYGAIN_TRACK_PEAK", meta.ReplayGainTrackPeak)
|
||||||
|
writeTXXX("REPLAYGAIN_ALBUM_GAIN", meta.ReplayGainAlbumGain)
|
||||||
|
writeTXXX("REPLAYGAIN_ALBUM_PEAK", meta.ReplayGainAlbumPeak)
|
||||||
|
|
||||||
|
if len(coverData) > 0 {
|
||||||
|
if strings.TrimSpace(coverMIME) == "" {
|
||||||
|
coverMIME = "image/jpeg"
|
||||||
|
}
|
||||||
|
// APIC: encoding + mime(null) + picture-type(0x03 front) + desc(null) + data
|
||||||
|
payload := []byte{0x03}
|
||||||
|
payload = append(payload, []byte(coverMIME)...)
|
||||||
|
payload = append(payload, 0x00)
|
||||||
|
payload = append(payload, 0x03)
|
||||||
|
payload = append(payload, 0x00)
|
||||||
|
payload = append(payload, coverData...)
|
||||||
|
writeFrame("APIC", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := frames.Bytes()
|
||||||
|
var out bytes.Buffer
|
||||||
|
out.WriteString("ID3")
|
||||||
|
out.Write([]byte{0x04, 0x00}) // v2.4.0
|
||||||
|
out.WriteByte(0x00) // flags
|
||||||
|
out.Write(synchsafeEncode(len(body)))
|
||||||
|
out.Write(body)
|
||||||
|
return out.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeID3Chunk rewrites filePath, replacing any existing tag chunk (chunkID,
|
||||||
|
// matched case-insensitively) with a fresh ID3v2.4 chunk appended at the end.
|
||||||
|
// The audio data and all other chunks are preserved; container size is patched.
|
||||||
|
func writeID3Chunk(filePath, expectMagic, chunkID string, le bool, id3 []byte) error {
|
||||||
|
in, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
|
||||||
|
header := make([]byte, 12)
|
||||||
|
if _, err := io.ReadFull(in, header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if string(header[0:4]) != expectMagic {
|
||||||
|
return fmt.Errorf("unexpected container magic %q", string(header[0:4]))
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := filePath + ".tagtmp"
|
||||||
|
out, err := os.Create(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cleanup := func() {
|
||||||
|
out.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := out.Write(header); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyLen int64 = 4 // the 4-byte form type after the size field
|
||||||
|
hdr := make([]byte, 8)
|
||||||
|
for {
|
||||||
|
n, rerr := io.ReadFull(in, hdr)
|
||||||
|
if n < 8 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if rerr != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
id := string(hdr[0:4])
|
||||||
|
size := readUint32(hdr[4:8], le)
|
||||||
|
pad := int64(size) & 1
|
||||||
|
|
||||||
|
if strings.EqualFold(id, chunkID) {
|
||||||
|
if _, err := in.Seek(int64(size)+pad, io.SeekCurrent); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := out.Write(hdr); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.CopyN(out, in, int64(size)+pad); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
bodyLen += 8 + int64(size) + pad
|
||||||
|
}
|
||||||
|
|
||||||
|
newSize := len(id3)
|
||||||
|
chunkHdr := make([]byte, 8)
|
||||||
|
copy(chunkHdr[0:4], chunkID)
|
||||||
|
putUint32(chunkHdr[4:8], le, uint32(newSize))
|
||||||
|
if _, err := out.Write(chunkHdr); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := out.Write(id3); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if newSize&1 == 1 {
|
||||||
|
if _, err := out.Write([]byte{0}); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bodyLen += 8 + int64(newSize) + int64(newSize&1)
|
||||||
|
|
||||||
|
// Patch the container size field (bytes 4..8).
|
||||||
|
sizeBuf := make([]byte, 4)
|
||||||
|
putUint32(sizeBuf, le, uint32(bodyLen))
|
||||||
|
if _, err := out.WriteAt(sizeBuf, 4); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := out.Close(); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in.Close()
|
||||||
|
|
||||||
|
return os.Rename(tmpPath, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCoverForTag(fields map[string]string) ([]byte, string) {
|
||||||
|
coverPath := strings.TrimSpace(fields["cover_path"])
|
||||||
|
if coverPath == "" {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(coverPath)
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
mime := "image/jpeg"
|
||||||
|
if len(data) >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 {
|
||||||
|
mime = "image/png"
|
||||||
|
}
|
||||||
|
return data, mime
|
||||||
|
}
|
||||||
|
|
||||||
|
func audioMetadataFromEditFields(fields map[string]string) *AudioMetadata {
|
||||||
|
atoi := func(k string) int {
|
||||||
|
n := 0
|
||||||
|
if v, ok := fields[k]; ok && strings.TrimSpace(v) != "" {
|
||||||
|
fmt.Sscanf(strings.TrimSpace(v), "%d", &n)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return &AudioMetadata{
|
||||||
|
Title: fields["title"],
|
||||||
|
Artist: fields["artist"],
|
||||||
|
Album: fields["album"],
|
||||||
|
AlbumArtist: fields["album_artist"],
|
||||||
|
Date: fields["date"],
|
||||||
|
TrackNumber: atoi("track_number"),
|
||||||
|
TotalTracks: atoi("track_total"),
|
||||||
|
DiscNumber: atoi("disc_number"),
|
||||||
|
TotalDiscs: atoi("disc_total"),
|
||||||
|
ISRC: fields["isrc"],
|
||||||
|
Lyrics: fields["lyrics"],
|
||||||
|
Genre: fields["genre"],
|
||||||
|
Label: fields["label"],
|
||||||
|
Copyright: fields["copyright"],
|
||||||
|
Composer: fields["composer"],
|
||||||
|
Comment: fields["comment"],
|
||||||
|
ReplayGainTrackGain: fields["replaygain_track_gain"],
|
||||||
|
ReplayGainTrackPeak: fields["replaygain_track_peak"],
|
||||||
|
ReplayGainAlbumGain: fields["replaygain_album_gain"],
|
||||||
|
ReplayGainAlbumPeak: fields["replaygain_album_peak"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeWAVEditFields merges edit fields onto existing tags so untouched fields
|
||||||
|
// (and cover art, when no new cover is provided) are preserved.
|
||||||
|
func mergeEditFieldsOntoExisting(existing *AudioMetadata, fields map[string]string) *AudioMetadata {
|
||||||
|
meta := audioMetadataFromEditFields(fields)
|
||||||
|
if existing == nil {
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
// Only overwrite fields that are present as keys in the edit set; otherwise
|
||||||
|
// keep the existing value. An empty value with the key present clears it.
|
||||||
|
keep := func(key, newVal, oldVal string) string {
|
||||||
|
if _, ok := fields[key]; ok {
|
||||||
|
return newVal
|
||||||
|
}
|
||||||
|
return oldVal
|
||||||
|
}
|
||||||
|
meta.Title = keep("title", meta.Title, existing.Title)
|
||||||
|
meta.Artist = keep("artist", meta.Artist, existing.Artist)
|
||||||
|
meta.Album = keep("album", meta.Album, existing.Album)
|
||||||
|
meta.AlbumArtist = keep("album_artist", meta.AlbumArtist, existing.AlbumArtist)
|
||||||
|
meta.Genre = keep("genre", meta.Genre, existing.Genre)
|
||||||
|
meta.Composer = keep("composer", meta.Composer, existing.Composer)
|
||||||
|
meta.Label = keep("label", meta.Label, existing.Label)
|
||||||
|
meta.Copyright = keep("copyright", meta.Copyright, existing.Copyright)
|
||||||
|
meta.ISRC = keep("isrc", meta.ISRC, existing.ISRC)
|
||||||
|
meta.Lyrics = keep("lyrics", meta.Lyrics, existing.Lyrics)
|
||||||
|
meta.Comment = keep("comment", meta.Comment, existing.Comment)
|
||||||
|
meta.Date = keep("date", meta.Date, existing.Date)
|
||||||
|
if _, ok := fields["track_number"]; !ok {
|
||||||
|
meta.TrackNumber = existing.TrackNumber
|
||||||
|
}
|
||||||
|
if _, ok := fields["track_total"]; !ok {
|
||||||
|
meta.TotalTracks = existing.TotalTracks
|
||||||
|
}
|
||||||
|
if _, ok := fields["disc_number"]; !ok {
|
||||||
|
meta.DiscNumber = existing.DiscNumber
|
||||||
|
}
|
||||||
|
if _, ok := fields["disc_total"]; !ok {
|
||||||
|
meta.TotalDiscs = existing.TotalDiscs
|
||||||
|
}
|
||||||
|
if _, ok := fields["replaygain_track_gain"]; !ok {
|
||||||
|
meta.ReplayGainTrackGain = existing.ReplayGainTrackGain
|
||||||
|
}
|
||||||
|
if _, ok := fields["replaygain_track_peak"]; !ok {
|
||||||
|
meta.ReplayGainTrackPeak = existing.ReplayGainTrackPeak
|
||||||
|
}
|
||||||
|
if _, ok := fields["replaygain_album_gain"]; !ok {
|
||||||
|
meta.ReplayGainAlbumGain = existing.ReplayGainAlbumGain
|
||||||
|
}
|
||||||
|
if _, ok := fields["replaygain_album_peak"]; !ok {
|
||||||
|
meta.ReplayGainAlbumPeak = existing.ReplayGainAlbumPeak
|
||||||
|
}
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteWAVTags writes/merges tags into a WAV file's "id3 " chunk.
|
||||||
|
func WriteWAVTags(filePath string, fields map[string]string) error {
|
||||||
|
existing, _ := ReadWAVTags(filePath)
|
||||||
|
meta := mergeEditFieldsOntoExisting(existing, fields)
|
||||||
|
|
||||||
|
coverData, coverMIME := loadCoverForTag(fields)
|
||||||
|
if coverData == nil {
|
||||||
|
// Preserve an existing embedded cover when no new one is supplied.
|
||||||
|
if f, err := os.Open(filePath); err == nil {
|
||||||
|
if p, perr := streamProbeWAV(f); perr == nil && len(p.id3) > 0 {
|
||||||
|
coverData, coverMIME = extractAPICFromID3(p.id3)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tag := buildID3v24Tag(meta, coverData, coverMIME)
|
||||||
|
return writeID3Chunk(filePath, "RIFF", id3ChunkWAV, true, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteAIFFTags writes/merges tags into an AIFF file's "ID3 " chunk.
|
||||||
|
func WriteAIFFTags(filePath string, fields map[string]string) error {
|
||||||
|
existing, _ := ReadAIFFTags(filePath)
|
||||||
|
meta := mergeEditFieldsOntoExisting(existing, fields)
|
||||||
|
|
||||||
|
coverData, coverMIME := loadCoverForTag(fields)
|
||||||
|
if coverData == nil {
|
||||||
|
if f, err := os.Open(filePath); err == nil {
|
||||||
|
if p, perr := streamProbeAIFF(f); perr == nil && len(p.id3) > 0 {
|
||||||
|
coverData, coverMIME = extractAPICFromID3(p.id3)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tag := buildID3v24Tag(meta, coverData, coverMIME)
|
||||||
|
return writeID3Chunk(filePath, "FORM", id3ChunkAIFF, false, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanWAVFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
|
if metadata, err := ReadWAVTags(filePath); err == nil && metadata != nil {
|
||||||
|
applyAudioMetadataToScan(metadata, result)
|
||||||
|
}
|
||||||
|
if quality, err := GetWAVQuality(filePath); err == nil && quality != nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
result.Duration = quality.Duration
|
||||||
|
}
|
||||||
|
result.Bitrate = 0 // lossless PCM
|
||||||
|
result.Format = "wav"
|
||||||
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanAIFFFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||||
|
if metadata, err := ReadAIFFTags(filePath); err == nil && metadata != nil {
|
||||||
|
applyAudioMetadataToScan(metadata, result)
|
||||||
|
}
|
||||||
|
if quality, err := GetAIFFQuality(filePath); err == nil && quality != nil {
|
||||||
|
result.BitDepth = quality.BitDepth
|
||||||
|
result.SampleRate = quality.SampleRate
|
||||||
|
result.Duration = quality.Duration
|
||||||
|
}
|
||||||
|
result.Bitrate = 0 // lossless PCM
|
||||||
|
result.Format = "aiff"
|
||||||
|
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAudioMetadataToScan(metadata *AudioMetadata, result *LibraryScanResult) {
|
||||||
|
result.TrackName = metadata.Title
|
||||||
|
result.ArtistName = metadata.Artist
|
||||||
|
result.AlbumName = metadata.Album
|
||||||
|
result.AlbumArtist = metadata.AlbumArtist
|
||||||
|
result.ISRC = metadata.ISRC
|
||||||
|
result.TrackNumber = metadata.TrackNumber
|
||||||
|
result.TotalTracks = metadata.TotalTracks
|
||||||
|
result.DiscNumber = metadata.DiscNumber
|
||||||
|
result.TotalDiscs = metadata.TotalDiscs
|
||||||
|
if metadata.Date != "" {
|
||||||
|
result.ReleaseDate = metadata.Date
|
||||||
|
} else {
|
||||||
|
result.ReleaseDate = metadata.Year
|
||||||
|
}
|
||||||
|
result.Genre = metadata.Genre
|
||||||
|
result.Composer = metadata.Composer
|
||||||
|
result.Label = metadata.Label
|
||||||
|
result.Copyright = metadata.Copyright
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractWAVAIFFCover returns embedded cover art (from the ID3 chunk) for a
|
||||||
|
// WAV or AIFF file, or an error when none is present.
|
||||||
|
func extractWAVAIFFCover(filePath string) ([]byte, string, error) {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
var id3 []byte
|
||||||
|
switch ext {
|
||||||
|
case ".aiff", ".aif", ".aifc":
|
||||||
|
if p, perr := streamProbeAIFF(f); perr == nil {
|
||||||
|
id3 = p.id3
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if p, perr := streamProbeWAV(f); perr == nil {
|
||||||
|
id3 = p.id3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(id3) == 0 {
|
||||||
|
return nil, "", fmt.Errorf("no embedded cover")
|
||||||
|
}
|
||||||
|
data, mime := extractAPICFromID3(id3)
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil, "", fmt.Errorf("no embedded cover")
|
||||||
|
}
|
||||||
|
return data, mime, nil
|
||||||
|
}
|
||||||
@@ -21,6 +21,6 @@
|
|||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>MinimumOSVersion</key>
|
<key>MinimumOSVersion</key>
|
||||||
<string>13.0</string>
|
<string>14.0</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -346,7 +346,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -472,7 +472,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -523,7 +523,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
import Gobackend // Import Go framework
|
import Gobackend
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
@@ -17,9 +17,16 @@ import Gobackend // Import Go framework
|
|||||||
private var libraryScanProgressTimer: DispatchSourceTimer?
|
private var libraryScanProgressTimer: DispatchSourceTimer?
|
||||||
private var libraryScanProgressEventSink: FlutterEventSink?
|
private var libraryScanProgressEventSink: FlutterEventSink?
|
||||||
private var lastLibraryScanProgressPayload: String?
|
private var lastLibraryScanProgressPayload: String?
|
||||||
|
private var backendChannel: FlutterMethodChannel?
|
||||||
|
private var pendingSessionGrantEvents: [[String: Any]] = []
|
||||||
|
|
||||||
/// Currently accessed security-scoped URL for library folder
|
/// Currently accessed security-scoped URL for library folder
|
||||||
private var activeSecurityScopedURL: URL?
|
private var activeSecurityScopedURL: URL?
|
||||||
|
|
||||||
|
/// Whether a download queue is active; while true a background task is
|
||||||
|
/// started on each background entry to extend execution time. Main-thread only.
|
||||||
|
private var downloadsActive = false
|
||||||
|
private var downloadBackgroundTask: UIBackgroundTaskIdentifier = .invalid
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
@@ -34,6 +41,14 @@ import Gobackend // Import Go framework
|
|||||||
name: CHANNEL,
|
name: CHANNEL,
|
||||||
binaryMessenger: controller.binaryMessenger
|
binaryMessenger: controller.binaryMessenger
|
||||||
)
|
)
|
||||||
|
backendChannel = channel
|
||||||
|
if !pendingSessionGrantEvents.isEmpty {
|
||||||
|
let events = pendingSessionGrantEvents
|
||||||
|
pendingSessionGrantEvents.removeAll()
|
||||||
|
for event in events {
|
||||||
|
channel.invokeMethod("extensionSessionGrantCompleted", arguments: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
let downloadProgressEvents = FlutterEventChannel(
|
let downloadProgressEvents = FlutterEventChannel(
|
||||||
name: DOWNLOAD_PROGRESS_STREAM_CHANNEL,
|
name: DOWNLOAD_PROGRESS_STREAM_CHANNEL,
|
||||||
binaryMessenger: controller.binaryMessenger
|
binaryMessenger: controller.binaryMessenger
|
||||||
@@ -78,20 +93,25 @@ import Gobackend // Import Go framework
|
|||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PKCE OAuth return URL: spotiflac://callback?code=...&state=<extension_id>
|
/// Extension return URLs:
|
||||||
|
/// - OAuth: spotiflac://callback?code=...&state=<extension_id>
|
||||||
|
/// - Signed session: spotiflac://session-grant?grant=...&state=<extension_id>
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func handleExtensionOAuthRedirect(url: URL) -> Bool {
|
private func handleExtensionOAuthRedirect(url: URL) -> Bool {
|
||||||
guard let scheme = url.scheme?.lowercased(), scheme == "spotiflac" else { return false }
|
guard let scheme = url.scheme?.lowercased(), scheme == "spotiflac" else { return false }
|
||||||
let host = (url.host ?? "").lowercased()
|
let host = (url.host ?? "").lowercased()
|
||||||
let path = url.path.lowercased()
|
let path = url.path.lowercased()
|
||||||
|
let isSessionGrant = host == "session-grant"
|
||||||
let ok =
|
let ok =
|
||||||
host == "callback" || host == "spotify-callback" || path.contains("callback")
|
isSessionGrant || host == "callback" || host == "spotify-callback" || path.contains("callback")
|
||||||
guard ok else { return false }
|
guard ok else { return false }
|
||||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
let q = components.queryItems ?? []
|
let q = components.queryItems ?? []
|
||||||
let code =
|
let code =
|
||||||
|
q.first { $0.name == (isSessionGrant ? "grant" : "code") }?.value?.trimmingCharacters(
|
||||||
|
in: .whitespacesAndNewlines) ??
|
||||||
q.first { $0.name == "code" }?.value?.trimmingCharacters(
|
q.first { $0.name == "code" }?.value?.trimmingCharacters(
|
||||||
in: .whitespacesAndNewlines) ?? ""
|
in: .whitespacesAndNewlines) ?? ""
|
||||||
let state =
|
let state =
|
||||||
@@ -104,16 +124,37 @@ import Gobackend // Import Go framework
|
|||||||
}
|
}
|
||||||
streamQueue.async {
|
streamQueue.async {
|
||||||
var err: NSError?
|
var err: NSError?
|
||||||
GobackendSetExtensionAuthCodeByID(state, code)
|
if isSessionGrant {
|
||||||
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
|
GobackendSetExtensionSessionGrantByID(state, code)
|
||||||
|
_ = GobackendInvokeExtensionActionJSON(state, "completeGrant", &err)
|
||||||
|
} else {
|
||||||
|
GobackendSetExtensionAuthCodeByID(state, code)
|
||||||
|
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
|
||||||
|
}
|
||||||
if let err = err {
|
if let err = err {
|
||||||
NSLog(
|
NSLog(
|
||||||
"SpotiFLAC: Extension OAuth complete failed: \(err.localizedDescription)")
|
"SpotiFLAC: Extension callback complete failed: \(err.localizedDescription)")
|
||||||
|
} else if isSessionGrant {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.notifySessionGrantCompleted(extensionId: state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func notifySessionGrantCompleted(extensionId: String) {
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"extension_id": extensionId,
|
||||||
|
"success": true,
|
||||||
|
]
|
||||||
|
if let channel = backendChannel {
|
||||||
|
channel.invokeMethod("extensionSessionGrantCompleted", arguments: payload)
|
||||||
|
} else {
|
||||||
|
pendingSessionGrantEvents.append(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ app: UIApplication,
|
_ app: UIApplication,
|
||||||
open url: URL,
|
open url: URL,
|
||||||
@@ -233,6 +274,20 @@ import Gobackend // Import Go framework
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
private func handleMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
|
switch call.method {
|
||||||
|
case "beginBackgroundDownloadTask":
|
||||||
|
downloadsActive = true
|
||||||
|
result(nil)
|
||||||
|
return
|
||||||
|
case "endBackgroundDownloadTask":
|
||||||
|
downloadsActive = false
|
||||||
|
endBackgroundDownloadTask()
|
||||||
|
result(nil)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
do {
|
do {
|
||||||
let response = try self.invokeGoMethod(call: call)
|
let response = try self.invokeGoMethod(call: call)
|
||||||
@@ -246,6 +301,34 @@ import Gobackend // Import Go framework
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func applicationDidEnterBackground(_ application: UIApplication) {
|
||||||
|
super.applicationDidEnterBackground(application)
|
||||||
|
if downloadsActive {
|
||||||
|
beginBackgroundDownloadTask()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func applicationWillEnterForeground(_ application: UIApplication) {
|
||||||
|
super.applicationWillEnterForeground(application)
|
||||||
|
endBackgroundDownloadTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginBackgroundDownloadTask() {
|
||||||
|
if downloadBackgroundTask != .invalid { return }
|
||||||
|
downloadBackgroundTask = UIApplication.shared.beginBackgroundTask(
|
||||||
|
withName: "SpotiFLACDownloads"
|
||||||
|
) { [weak self] in
|
||||||
|
self?.endBackgroundDownloadTask()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func endBackgroundDownloadTask() {
|
||||||
|
if downloadBackgroundTask != .invalid {
|
||||||
|
UIApplication.shared.endBackgroundTask(downloadBackgroundTask)
|
||||||
|
downloadBackgroundTask = .invalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func invokeGoMethod(call: FlutterMethodCall) throws -> Any? {
|
private func invokeGoMethod(call: FlutterMethodCall) throws -> Any? {
|
||||||
var error: NSError?
|
var error: NSError?
|
||||||
@@ -310,6 +393,12 @@ import Gobackend // Import Go framework
|
|||||||
let insecureTLS = args["insecure_tls"] as? Bool ?? false
|
let insecureTLS = args["insecure_tls"] as? Bool ?? false
|
||||||
GobackendSetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
|
GobackendSetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case "setAllowPrivateNetwork":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let allowed = args["allowed"] as? Bool ?? false
|
||||||
|
GobackendSetAllowPrivateNetwork(allowed)
|
||||||
|
return nil
|
||||||
|
|
||||||
case "checkDuplicate":
|
case "checkDuplicate":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
@@ -543,7 +632,6 @@ import Gobackend // Import Go framework
|
|||||||
GobackendClearTrackCache()
|
GobackendClearTrackCache()
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
// Log methods
|
|
||||||
case "getLogs":
|
case "getLogs":
|
||||||
let response = GobackendGetLogs()
|
let response = GobackendGetLogs()
|
||||||
return response
|
return response
|
||||||
@@ -568,7 +656,6 @@ import Gobackend // Import Go framework
|
|||||||
GobackendSetLoggingEnabled(enabled)
|
GobackendSetLoggingEnabled(enabled)
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
// Extension System methods
|
|
||||||
case "initExtensionSystem":
|
case "initExtensionSystem":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let extensionsDir = args["extensions_dir"] as! String
|
let extensionsDir = args["extensions_dir"] as! String
|
||||||
@@ -733,7 +820,6 @@ import Gobackend // Import Go framework
|
|||||||
GobackendCleanupExtensions()
|
GobackendCleanupExtensions()
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
// Extension Auth API
|
|
||||||
case "getExtensionPendingAuth":
|
case "getExtensionPendingAuth":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let extensionId = args["extension_id"] as! String
|
let extensionId = args["extension_id"] as! String
|
||||||
@@ -774,7 +860,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
// Extension FFmpeg API
|
|
||||||
case "getPendingFFmpegCommand":
|
case "getPendingFFmpegCommand":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let commandId = args["command_id"] as! String
|
let commandId = args["command_id"] as! String
|
||||||
@@ -796,7 +881,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
// Extension Custom Search API
|
|
||||||
case "customSearchWithExtension":
|
case "customSearchWithExtension":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let extensionId = args["extension_id"] as! String
|
let extensionId = args["extension_id"] as! String
|
||||||
@@ -818,7 +902,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
// Extension URL Handler API
|
|
||||||
case "handleURLWithExtension":
|
case "handleURLWithExtension":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let url = args["url"] as! String
|
let url = args["url"] as! String
|
||||||
@@ -837,7 +920,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
// Extension Post-Processing API
|
|
||||||
case "runPostProcessing":
|
case "runPostProcessing":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let filePath = args["file_path"] as! String
|
let filePath = args["file_path"] as! String
|
||||||
@@ -859,7 +941,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
// Extension Store
|
|
||||||
case "initExtensionStore":
|
case "initExtensionStore":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let cacheDir = args["cache_dir"] as! String
|
let cacheDir = args["cache_dir"] as! String
|
||||||
@@ -917,7 +998,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
// Extension Home Feed API
|
|
||||||
case "getExtensionHomeFeed":
|
case "getExtensionHomeFeed":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let extensionId = args["extension_id"] as! String
|
let extensionId = args["extension_id"] as! String
|
||||||
@@ -933,7 +1013,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
// Local Library Scanning
|
|
||||||
case "setLibraryCoverCacheDir":
|
case "setLibraryCoverCacheDir":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let cacheDir = args["cache_dir"] as! String
|
let cacheDir = args["cache_dir"] as! String
|
||||||
@@ -970,7 +1049,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
// iOS Security-Scoped Bookmark for Local Library
|
|
||||||
case "resolveIosBookmark":
|
case "resolveIosBookmark":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let bookmarkBase64 = args["bookmark"] as! String
|
let bookmarkBase64 = args["bookmark"] as! String
|
||||||
@@ -990,7 +1068,6 @@ import Gobackend // Import Go framework
|
|||||||
let path = args["path"] as! String
|
let path = args["path"] as! String
|
||||||
return try createIosBookmarkFromPath(path)
|
return try createIosBookmarkFromPath(path)
|
||||||
|
|
||||||
// Lyrics Provider Settings
|
|
||||||
case "setLyricsProviders":
|
case "setLyricsProviders":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let providersJson = args["providers_json"] as? String ?? "[]"
|
let providersJson = args["providers_json"] as? String ?? "[]"
|
||||||
@@ -1020,7 +1097,6 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
// CUE Sheet Parsing
|
|
||||||
case "parseCueSheet":
|
case "parseCueSheet":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let cuePath = args["cue_path"] as! String
|
let cuePath = args["cue_path"] as! String
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:spotiflac_android/screens/main_shell.dart';
|
|||||||
import 'package:spotiflac_android/screens/setup_screen.dart';
|
import 'package:spotiflac_android/screens/setup_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/tutorial_screen.dart';
|
import 'package:spotiflac_android/screens/tutorial_screen.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/services/app_navigation_service.dart';
|
||||||
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
||||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
|
navigatorKey: AppNavigationService.rootNavigatorKey,
|
||||||
initialLocation: initialLocation,
|
initialLocation: initialLocation,
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(path: '/', builder: (context, state) => const MainShell()),
|
GoRoute(path: '/', builder: (context, state) => const MainShell()),
|
||||||
@@ -114,6 +116,15 @@ class SpotiFLACApp extends ConsumerWidget {
|
|||||||
scrollBehavior: scrollBehavior,
|
scrollBehavior: scrollBehavior,
|
||||||
themeAnimationDuration: const Duration(milliseconds: 300),
|
themeAnimationDuration: const Duration(milliseconds: 300),
|
||||||
themeAnimationCurve: Curves.easeInOut,
|
themeAnimationCurve: Curves.easeInOut,
|
||||||
|
// Treat the display as one continuous surface so bottom sheets and
|
||||||
|
// dialogs stay centered on large/foldable devices.
|
||||||
|
builder: (context, child) {
|
||||||
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
return MediaQuery(
|
||||||
|
data: mediaQuery.copyWith(displayFeatures: const []),
|
||||||
|
child: child ?? const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
},
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
locale: locale,
|
locale: locale,
|
||||||
localeResolutionCallback: (deviceLocale, supportedLocales) {
|
localeResolutionCallback: (deviceLocale, supportedLocales) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '4.5.5';
|
static const String version = '4.7.1';
|
||||||
static const String buildNumber = '132';
|
static const String buildNumber = '137';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
||||||
|
|||||||
+947
-56
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+992
-395
File diff suppressed because it is too large
Load Diff
@@ -142,7 +142,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsSwitchBack =>
|
String get optionsSwitchBack =>
|
||||||
'Tap Deezer or Spotify to switch back from extension';
|
'Choose the default search provider to switch back from an extension';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsAutoFallback => 'Auto Fallback';
|
String get optionsAutoFallback => 'Auto Fallback';
|
||||||
@@ -155,10 +155,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
String get optionsUseExtensionProvidersOn =>
|
||||||
|
'Extension providers are enabled';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
|
String get optionsUseExtensionProvidersOff =>
|
||||||
|
'Extension providers are required';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsEmbedLyrics => 'Embed Lyrics';
|
String get optionsEmbedLyrics => 'Embed Lyrics';
|
||||||
@@ -185,6 +187,43 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get optionsReplayGainSubtitleOff =>
|
String get optionsReplayGainSubtitleOff =>
|
||||||
'Disabled: no loudness normalization tags';
|
'Disabled: no loudness normalization tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReplayGain => 'Rescan ReplayGain';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReplayGainSubtitle =>
|
||||||
|
'Analyze loudness and write ReplayGain tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReplayGainScanning => 'Analyzing loudness...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReplayGainSuccess => 'ReplayGain tags added';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackReplayGainFailed => 'Failed to add ReplayGain tags';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionReplayGainCount(int count) {
|
||||||
|
return 'ReplayGain ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get replayGainBatchConfirmTitle => 'Add ReplayGain';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String replayGainBatchConfirmMessage(int count) {
|
||||||
|
return 'Analyze loudness and write ReplayGain tags to $count track(s)?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get replayGainBatchAnalyzing => 'Analyzing ReplayGain...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String replayGainBatchSuccess(int success, int total) {
|
||||||
|
return 'ReplayGain added to $success of $total tracks';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||||
|
|
||||||
@@ -206,21 +245,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get optionsConcurrentDownloads => 'Concurrent Downloads';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String optionsConcurrentParallel(int count) {
|
|
||||||
return '$count parallel downloads';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get optionsConcurrentWarning =>
|
|
||||||
'Parallel downloads may trigger rate limiting';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get optionsExtensionStore => 'Extension Repo';
|
String get optionsExtensionStore => 'Extension Repo';
|
||||||
|
|
||||||
@@ -385,11 +409,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutBinimumDesc =>
|
String get aboutBinimumDesc =>
|
||||||
'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!';
|
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutSachinsenalDesc =>
|
String get aboutSachinsenalDesc =>
|
||||||
'The original HiFi project creator. The foundation of Tidal integration!';
|
'The original HiFi project creator. A foundation for lossless-source integration.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get aboutSjdonadoDesc =>
|
String get aboutSjdonadoDesc =>
|
||||||
@@ -579,6 +603,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get dialogDownload => 'Download';
|
String get dialogDownload => 'Download';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewPlay => 'Play preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewStop => 'Stop preview';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get previewUnavailable => 'Preview unavailable';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dialogDiscard => 'Discard';
|
String get dialogDiscard => 'Discard';
|
||||||
|
|
||||||
@@ -742,6 +775,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get errorNoTracksFound => 'No tracks found';
|
String get errorNoTracksFound => 'No tracks found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get errorUrlNotRecognized => 'Link not recognized';
|
String get errorUrlNotRecognized => 'Link not recognized';
|
||||||
|
|
||||||
@@ -950,7 +986,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
'Only enabled extensions with download-provider capability are listed here.';
|
'Only enabled extensions with download-provider capability are listed here.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerBuiltIn => 'Built-in';
|
String get providerBuiltIn => 'Legacy';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get providerExtension => 'Extension';
|
String get providerExtension => 'Extension';
|
||||||
@@ -1343,10 +1379,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get storeEmptyNoResults => 'No extensions found';
|
String get storeEmptyNoResults => 'No extensions found';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
String get extensionDefaultProvider => 'Default Search';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
String get extensionDefaultProviderSubtitle =>
|
||||||
|
'Use the default metadata search';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get extensionAuthor => 'Author';
|
String get extensionAuthor => 'Author';
|
||||||
@@ -1520,7 +1557,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadLossy320FormatDesc =>
|
String get downloadLossy320FormatDesc =>
|
||||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||||
@@ -1564,6 +1601,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get albumFolderStructureDescription =>
|
||||||
|
'Choose how album folders are structured';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||||
|
|
||||||
@@ -1907,7 +1948,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get libraryAboutDescription =>
|
String get libraryAboutDescription =>
|
||||||
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.';
|
'Scans your existing music collection to detect duplicates when downloading. Supports FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, and APE formats. Metadata is read from file tags when available.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String libraryTracksUnit(int count) {
|
String libraryTracksUnit(int count) {
|
||||||
@@ -2090,7 +2131,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip2 =>
|
String get tutorialWelcomeTip2 =>
|
||||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
'Get FLAC quality audio from installed download extensions';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tutorialWelcomeTip3 =>
|
String get tutorialWelcomeTip3 =>
|
||||||
@@ -2788,7 +2829,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsProvidersInfoText =>
|
String get lyricsProvidersInfoText =>
|
||||||
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
|
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String lyricsProvidersEnabledSection(int count) {
|
String lyricsProvidersEnabledSection(int count) {
|
||||||
@@ -2830,6 +2871,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get lyricsProviderQqMusicDesc =>
|
String get lyricsProviderQqMusicDesc =>
|
||||||
'QQ Music (good for Chinese songs, via proxy)';
|
'QQ Music (good for Chinese songs, via proxy)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lyricsProviderLyricsPlusDesc =>
|
||||||
|
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||||
|
|
||||||
@@ -2853,6 +2898,164 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
String get settingsDonateSubtitle => 'Buy the developer a coffee';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackup => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsBackupSubtitle =>
|
||||||
|
'Move your library, history and settings to a new device';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupTitle => 'Backup & Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionTitle => 'Create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportSectionDescription =>
|
||||||
|
'Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupExportButton => 'Create backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionTitle => 'Restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportSectionDescription =>
|
||||||
|
'Pick a backup file to restore your data. This replaces the current settings, history and library on this device.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupImportButton => 'Choose backup file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreating => 'Creating backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreated => 'Backup created';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupCreateFailed => 'Failed to create backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupEmpty => 'There is nothing to back up yet';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmTitle => 'Restore this backup?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmMessage =>
|
||||||
|
'This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreConfirmButton => 'Restore';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoring => 'Restoring backup...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestored => 'Backup restored successfully';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreFailed => 'Failed to restore backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupInvalidFile => 'This file is not a valid SpotiFLAC backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupRestoreRestartHint =>
|
||||||
|
'Restart the app to make sure every change is applied.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsTitle => 'Backup contents';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupContentsSettings => 'App settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsHistory(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'items',
|
||||||
|
one: 'item',
|
||||||
|
);
|
||||||
|
return '$count history $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsLiked(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count liked $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsWishlist(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return '$count wishlist $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsPlaylists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count playlists',
|
||||||
|
one: '1 playlist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsArtists(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count favorite artists',
|
||||||
|
one: '1 favorite artist',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupContentsExtensions(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count extensions',
|
||||||
|
one: '1 extension',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecrets => 'Include extension credentials';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get backupIncludeSecretsDescription =>
|
||||||
|
'Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String backupExtensionsRestoreFailed(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'extensions',
|
||||||
|
one: 'extension',
|
||||||
|
);
|
||||||
|
return '$count $_temp0 could not be reinstalled. Install them manually from the store.';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tooltipLoveAll => 'Love All';
|
String get tooltipLoveAll => 'Love All';
|
||||||
|
|
||||||
@@ -2982,13 +3185,24 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get downloadNetworkCompatibilityModeDisabled =>
|
String get downloadNetworkCompatibilityModeDisabled =>
|
||||||
'Using standard network settings';
|
'Using standard network settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetwork => 'Allow Local Network Access';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkEnabled =>
|
||||||
|
'Requests to local/private addresses are allowed (for local proxy or custom DNS)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAllowLocalNetworkDisabled =>
|
||||||
|
'Local/private addresses are blocked for security';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectServiceToEnable =>
|
String get downloadSelectServiceToEnable =>
|
||||||
'Select Tidal or Qobuz to enable this option';
|
'Select a provider with quality options to enable this option';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadSelectTidalQobuz =>
|
String get downloadSelectTidalQobuz =>
|
||||||
'Select Tidal or Qobuz to choose audio quality';
|
'Select a provider with quality options to choose audio quality';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
|
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
|
||||||
@@ -3667,6 +3881,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get queueDownloadCompleted => 'Download completed';
|
String get queueDownloadCompleted => 'Download completed';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueRateLimitTitle => 'Service rate limited';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get queueRateLimitMessage =>
|
||||||
|
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String appearanceSelectAccentColor(String hex) {
|
String appearanceSelectAccentColor(String hex) {
|
||||||
return 'Select accent color $hex';
|
return 'Select accent color $hex';
|
||||||
@@ -4193,4 +4414,360 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get artistReleases => 'Releases';
|
String get artistReleases => 'Releases';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editMetadataSelectNone => 'None';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String queueRetryAllFailed(int count) {
|
||||||
|
return 'Retry $count failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsSaveDownloadHistory => 'Save download history';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsSaveDownloadHistorySubtitle =>
|
||||||
|
'Keep completed downloads in history and library views';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDisableHistoryTitle => 'Turn off download history?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDisableHistoryMessage =>
|
||||||
|
'Existing history will be cleared. Downloaded files will not be deleted.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dialogDisableAndClear => 'Turn off and clear';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get openInOtherServices => 'Open in Other Services';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shareSheetNoExtensions => 'No other compatible services';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shareSheetNotFound => 'Not found';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shareSheetCopyLink => 'Copy Link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String shareSheetLinkCopied(Object service) {
|
||||||
|
return '$service link copied';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryPlayback => 'Playback';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayer => 'External player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryExternalPlayerSubtitle =>
|
||||||
|
'Recommended for listening, best quality, gapless playback, EQ, and wider format support';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayer => 'Built-in preview player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPreviewPlayerSubtitle =>
|
||||||
|
'Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get libraryBuiltInPlayerInfo =>
|
||||||
|
'The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTitle => 'Now Playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNothingPlaying => 'Nothing is playing';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingMinimize => 'Minimize';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingUpNext => 'Up next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingDetails => 'Details';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingOpenInExternalPlayer => 'Open in external player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabPlayer => 'Player';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingTabLyrics => 'Lyrics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoLyrics => 'No lyrics in this file';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingLibraryEmpty => 'Your library is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String nowPlayingShuffleLibraryFailed(String error) {
|
||||||
|
return 'Could not shuffle library: $error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleOn => 'Shuffle on';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingPlayInOrder => 'Play in order';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingShuffleLibrary => 'Shuffle library';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingQueueEmpty => 'Queue is empty';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nowPlayingNoMetadata => 'No metadata available';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get announcementUnableToOpenLink =>
|
||||||
|
'Unable to open link. Please try again.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertLosslessOutputWithCap(String quality) {
|
||||||
|
return 'Lossless output with $quality cap';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertConfirmMessageLosslessCapped(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return 'Convert from $sourceFormat to $targetFormat ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionBatchConvertConfirmMessageLosslessCapped(
|
||||||
|
int count,
|
||||||
|
String format,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'tracks',
|
||||||
|
one: 'track',
|
||||||
|
);
|
||||||
|
return 'Convert $count $_temp0 to $format ($quality)?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossless(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String quality,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat ($quality)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String trackConvertActionLabelLossy(
|
||||||
|
String sourceFormat,
|
||||||
|
String targetFormat,
|
||||||
|
String bitrate,
|
||||||
|
) {
|
||||||
|
return '$sourceFormat → $targetFormat @ $bitrate';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get aboutPaxsenixSubtitle =>
|
||||||
|
'Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarPlayingNext => 'Playing next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snackbarAddedToQueueGeneric => 'Added to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String selectionDeletePlaylistsCount(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'playlists',
|
||||||
|
one: 'playlist',
|
||||||
|
);
|
||||||
|
return 'Delete $count $_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get actionShuffle => 'Shuffle';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOn => 'Primary only: On';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadPrimaryArtistOnlyOff => 'Primary only: Off';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataPrimaryOnly =>
|
||||||
|
'Album Artist metadata: Primary only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadAlbumArtistMetadataFull => 'Album Artist metadata: Full';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginal => 'Original';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertOriginalQuality => 'Original quality';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertLosslessSuffix => 'Lossless';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDithering => 'Dithering';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResampler => 'Resampler';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherNone => 'None';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangular => 'TPDF';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertDitherTriangularHp => 'Triangular HP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSwr => 'SWR';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackConvertResamplerSoxr => 'SoXr';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateSeeReleaseNotes => 'See release notes for details.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unknownTitle => 'Unknown title';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackPlayNext => 'Play next';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackAddToQueue => 'Add to queue';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionInstalledEnable(String extensionName) {
|
||||||
|
return '$extensionName installed. Enable it in Settings > Extensions';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarExtensionUpdatedVersion(String extensionName, String version) {
|
||||||
|
return '$extensionName updated to v$version';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToInstallNamed(String extensionName) {
|
||||||
|
return 'Failed to install $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String snackbarFailedToUpdateNamed(String extensionName) {
|
||||||
|
return 'Failed to update $extensionName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeEp => 'EP';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get releaseTypeSingle => 'Single';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trackCoverOnline => 'Online cover';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryUS => 'United States';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryGB => 'United Kingdom';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryFR => 'France';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryDE => 'Germany';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryJP => 'Japan';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryKR => 'South Korea';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryIN => 'India';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryID => 'Indonesia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryBR => 'Brazil';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryMX => 'Mexico';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryAU => 'Australia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryCA => 'Canada';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionCountryXK => 'Kosovo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserTitle => 'Verification browser';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleExternal =>
|
||||||
|
'Open challenges in the default browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserSubtitleInApp =>
|
||||||
|
'Open challenges in the in-app browser first';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserExternal => 'External';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationBrowserInApp => 'In-app';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleManual =>
|
||||||
|
'Open verification manually';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpTitleWaiting =>
|
||||||
|
'Verification still waiting';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageManual =>
|
||||||
|
'SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationHelpMessageWaiting =>
|
||||||
|
'If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationClose => 'Close';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationCopyLink => 'Copy link';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationLinkCopied => 'Verification link copied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get extensionVerificationOpenBrowser => 'Open browser';
|
||||||
}
|
}
|
||||||
|
|||||||
+1796
-452
File diff suppressed because it is too large
Load Diff
+1772
-1079
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1431
-106
File diff suppressed because it is too large
Load Diff
+803
-224
File diff suppressed because it is too large
Load Diff
+793
-206
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2271
-191
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1191
-235
File diff suppressed because it is too large
Load Diff
+771
-53
@@ -174,9 +174,9 @@
|
|||||||
"@optionsDefaultSearchTabSubtitle": {
|
"@optionsDefaultSearchTabSubtitle": {
|
||||||
"description": "Subtitle for the preferred default search tab setting"
|
"description": "Subtitle for the preferred default search tab setting"
|
||||||
},
|
},
|
||||||
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
|
"optionsSwitchBack": "Choose the default search provider to switch back from an extension",
|
||||||
"@optionsSwitchBack": {
|
"@optionsSwitchBack": {
|
||||||
"description": "Hint to switch back to built-in providers"
|
"description": "Hint to switch back from extension search"
|
||||||
},
|
},
|
||||||
"optionsAutoFallback": "Auto Fallback",
|
"optionsAutoFallback": "Auto Fallback",
|
||||||
"@optionsAutoFallback": {
|
"@optionsAutoFallback": {
|
||||||
@@ -188,15 +188,15 @@
|
|||||||
},
|
},
|
||||||
"optionsUseExtensionProviders": "Use Extension Providers",
|
"optionsUseExtensionProviders": "Use Extension Providers",
|
||||||
"@optionsUseExtensionProviders": {
|
"@optionsUseExtensionProviders": {
|
||||||
"description": "Enable extension download providers"
|
"description": "Legacy setting label for extension download providers"
|
||||||
},
|
},
|
||||||
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
|
"optionsUseExtensionProvidersOn": "Extension providers are enabled",
|
||||||
"@optionsUseExtensionProvidersOn": {
|
"@optionsUseExtensionProvidersOn": {
|
||||||
"description": "Status when extension providers enabled"
|
"description": "Status when extension providers enabled"
|
||||||
},
|
},
|
||||||
"optionsUseExtensionProvidersOff": "Using built-in providers only",
|
"optionsUseExtensionProvidersOff": "Extension providers are required",
|
||||||
"@optionsUseExtensionProvidersOff": {
|
"@optionsUseExtensionProvidersOff": {
|
||||||
"description": "Status when extension providers disabled"
|
"description": "Legacy status when extension providers would be disabled"
|
||||||
},
|
},
|
||||||
"optionsEmbedLyrics": "Embed Lyrics",
|
"optionsEmbedLyrics": "Embed Lyrics",
|
||||||
"@optionsEmbedLyrics": {
|
"@optionsEmbedLyrics": {
|
||||||
@@ -226,6 +226,64 @@
|
|||||||
"@optionsReplayGainSubtitleOff": {
|
"@optionsReplayGainSubtitleOff": {
|
||||||
"description": "Subtitle when ReplayGain is disabled"
|
"description": "Subtitle when ReplayGain is disabled"
|
||||||
},
|
},
|
||||||
|
"trackReplayGain": "Rescan ReplayGain",
|
||||||
|
"@trackReplayGain": {
|
||||||
|
"description": "Three-dot menu option to scan loudness and write ReplayGain tags"
|
||||||
|
},
|
||||||
|
"trackReplayGainSubtitle": "Analyze loudness and write ReplayGain tags",
|
||||||
|
"@trackReplayGainSubtitle": {
|
||||||
|
"description": "Subtitle for the rescan ReplayGain menu option"
|
||||||
|
},
|
||||||
|
"trackReplayGainScanning": "Analyzing loudness...",
|
||||||
|
"@trackReplayGainScanning": {
|
||||||
|
"description": "Snackbar/progress message while scanning ReplayGain for a single track"
|
||||||
|
},
|
||||||
|
"trackReplayGainSuccess": "ReplayGain tags added",
|
||||||
|
"@trackReplayGainSuccess": {
|
||||||
|
"description": "Snackbar message after ReplayGain tags written for a single track"
|
||||||
|
},
|
||||||
|
"trackReplayGainFailed": "Failed to add ReplayGain tags",
|
||||||
|
"@trackReplayGainFailed": {
|
||||||
|
"description": "Snackbar message when ReplayGain scan/write fails"
|
||||||
|
},
|
||||||
|
"selectionReplayGainCount": "ReplayGain ({count})",
|
||||||
|
"@selectionReplayGainCount": {
|
||||||
|
"description": "Batch selection action button label for ReplayGain",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"replayGainBatchConfirmTitle": "Add ReplayGain",
|
||||||
|
"@replayGainBatchConfirmTitle": {
|
||||||
|
"description": "Title of the batch ReplayGain confirmation dialog"
|
||||||
|
},
|
||||||
|
"replayGainBatchConfirmMessage": "Analyze loudness and write ReplayGain tags to {count} track(s)?",
|
||||||
|
"@replayGainBatchConfirmMessage": {
|
||||||
|
"description": "Message of the batch ReplayGain confirmation dialog",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"replayGainBatchAnalyzing": "Analyzing ReplayGain...",
|
||||||
|
"@replayGainBatchAnalyzing": {
|
||||||
|
"description": "Progress dialog title while batch scanning ReplayGain"
|
||||||
|
},
|
||||||
|
"replayGainBatchSuccess": "ReplayGain added to {success} of {total} tracks",
|
||||||
|
"@replayGainBatchSuccess": {
|
||||||
|
"description": "Snackbar after batch ReplayGain completes",
|
||||||
|
"placeholders": {
|
||||||
|
"success": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"total": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"optionsArtistTagMode": "Artist Tag Mode",
|
"optionsArtistTagMode": "Artist Tag Mode",
|
||||||
"@optionsArtistTagMode": {
|
"@optionsArtistTagMode": {
|
||||||
"description": "Setting title for how artist metadata is written into files"
|
"description": "Setting title for how artist metadata is written into files"
|
||||||
@@ -250,27 +308,6 @@
|
|||||||
"@optionsArtistTagModeSplitVorbisSubtitle": {
|
"@optionsArtistTagModeSplitVorbisSubtitle": {
|
||||||
"description": "Subtitle for split Vorbis artist tag mode"
|
"description": "Subtitle for split Vorbis artist tag mode"
|
||||||
},
|
},
|
||||||
"optionsConcurrentDownloads": "Concurrent Downloads",
|
|
||||||
"@optionsConcurrentDownloads": {
|
|
||||||
"description": "Number of parallel downloads"
|
|
||||||
},
|
|
||||||
"optionsConcurrentSequential": "Sequential (1 at a time)",
|
|
||||||
"@optionsConcurrentSequential": {
|
|
||||||
"description": "Download one at a time"
|
|
||||||
},
|
|
||||||
"optionsConcurrentParallel": "{count} parallel downloads",
|
|
||||||
"@optionsConcurrentParallel": {
|
|
||||||
"description": "Multiple parallel downloads",
|
|
||||||
"placeholders": {
|
|
||||||
"count": {
|
|
||||||
"type": "int"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
|
|
||||||
"@optionsConcurrentWarning": {
|
|
||||||
"description": "Warning about rate limits"
|
|
||||||
},
|
|
||||||
"optionsExtensionStore": "Extension Repo",
|
"optionsExtensionStore": "Extension Repo",
|
||||||
"@optionsExtensionStore": {
|
"@optionsExtensionStore": {
|
||||||
"description": "Show/hide store tab"
|
"description": "Show/hide store tab"
|
||||||
@@ -486,11 +523,11 @@
|
|||||||
"@aboutVersion": {
|
"@aboutVersion": {
|
||||||
"description": "Version info label"
|
"description": "Version info label"
|
||||||
},
|
},
|
||||||
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
|
"aboutBinimumDesc": "The creator of QQDL & HiFi API. This project helped shape lossless download support.",
|
||||||
"@aboutBinimumDesc": {
|
"@aboutBinimumDesc": {
|
||||||
"description": "Credit description for binimum"
|
"description": "Credit description for binimum"
|
||||||
},
|
},
|
||||||
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
|
"aboutSachinsenalDesc": "The original HiFi project creator. A foundation for lossless-source integration.",
|
||||||
"@aboutSachinsenalDesc": {
|
"@aboutSachinsenalDesc": {
|
||||||
"description": "Credit description for sachinsenal0x64"
|
"description": "Credit description for sachinsenal0x64"
|
||||||
},
|
},
|
||||||
@@ -735,6 +772,18 @@
|
|||||||
"@dialogDownload": {
|
"@dialogDownload": {
|
||||||
"description": "Confirm button in Download All dialog"
|
"description": "Confirm button in Download All dialog"
|
||||||
},
|
},
|
||||||
|
"previewPlay": "Play preview",
|
||||||
|
"@previewPlay": {
|
||||||
|
"description": "Tooltip for the button that plays a short track preview snippet"
|
||||||
|
},
|
||||||
|
"previewStop": "Stop preview",
|
||||||
|
"@previewStop": {
|
||||||
|
"description": "Tooltip for the button that stops the playing track preview snippet"
|
||||||
|
},
|
||||||
|
"previewUnavailable": "Preview unavailable",
|
||||||
|
"@previewUnavailable": {
|
||||||
|
"description": "Snackbar shown when a track preview snippet cannot be played"
|
||||||
|
},
|
||||||
"dialogDiscard": "Discard",
|
"dialogDiscard": "Discard",
|
||||||
"@dialogDiscard": {
|
"@dialogDiscard": {
|
||||||
"description": "Dialog button - discard changes"
|
"description": "Dialog button - discard changes"
|
||||||
@@ -961,6 +1010,10 @@
|
|||||||
"@errorNoTracksFound": {
|
"@errorNoTracksFound": {
|
||||||
"description": "Error - search returned no results"
|
"description": "Error - search returned no results"
|
||||||
},
|
},
|
||||||
|
"searchEmptyResultSubtitle": "Try another keyword",
|
||||||
|
"@searchEmptyResultSubtitle": {
|
||||||
|
"description": "Subtitle shown under the empty search result state on the home screen"
|
||||||
|
},
|
||||||
"errorUrlNotRecognized": "Link not recognized",
|
"errorUrlNotRecognized": "Link not recognized",
|
||||||
"@errorUrlNotRecognized": {
|
"@errorUrlNotRecognized": {
|
||||||
"description": "Error title - URL not handled by any extension or service"
|
"description": "Error title - URL not handled by any extension or service"
|
||||||
@@ -1231,9 +1284,9 @@
|
|||||||
"@providerPriorityFallbackExtensionsHint": {
|
"@providerPriorityFallbackExtensionsHint": {
|
||||||
"description": "Hint below the extension fallback selection list"
|
"description": "Hint below the extension fallback selection list"
|
||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Legacy",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
"description": "Legacy label retained for old generated localization compatibility"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1755,11 +1808,11 @@
|
|||||||
"@storeEmptyNoResults": {
|
"@storeEmptyNoResults": {
|
||||||
"description": "Message when search/filter returns no results"
|
"description": "Message when search/filter returns no results"
|
||||||
},
|
},
|
||||||
"extensionDefaultProvider": "Default (Deezer)",
|
"extensionDefaultProvider": "Default Search",
|
||||||
"@extensionDefaultProvider": {
|
"@extensionDefaultProvider": {
|
||||||
"description": "Default search provider option"
|
"description": "Default search provider option"
|
||||||
},
|
},
|
||||||
"extensionDefaultProviderSubtitle": "Use built-in search",
|
"extensionDefaultProviderSubtitle": "Use the default metadata search",
|
||||||
"@extensionDefaultProviderSubtitle": {
|
"@extensionDefaultProviderSubtitle": {
|
||||||
"description": "Subtitle for default provider"
|
"description": "Subtitle for default provider"
|
||||||
},
|
},
|
||||||
@@ -1988,51 +2041,51 @@
|
|||||||
},
|
},
|
||||||
"downloadLossy320": "Lossy 320kbps",
|
"downloadLossy320": "Lossy 320kbps",
|
||||||
"@downloadLossy320": {
|
"@downloadLossy320": {
|
||||||
"description": "Quality option label for Tidal lossy 320kbps"
|
"description": "Quality option label for lossy 320kbps"
|
||||||
},
|
},
|
||||||
"downloadLossyFormat": "Lossy Format",
|
"downloadLossyFormat": "Lossy Format",
|
||||||
"@downloadLossyFormat": {
|
"@downloadLossyFormat": {
|
||||||
"description": "Setting title to pick output format for Tidal lossy downloads"
|
"description": "Setting title to pick output format for lossy downloads"
|
||||||
},
|
},
|
||||||
"downloadLossy320Format": "Lossy 320kbps Format",
|
"downloadLossy320Format": "Lossy 320kbps Format",
|
||||||
"@downloadLossy320Format": {
|
"@downloadLossy320Format": {
|
||||||
"description": "Title of the Tidal lossy format picker bottom sheet"
|
"description": "Title of the lossy format picker bottom sheet"
|
||||||
},
|
},
|
||||||
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
|
"downloadLossy320FormatDesc": "Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.",
|
||||||
"@downloadLossy320FormatDesc": {
|
"@downloadLossy320FormatDesc": {
|
||||||
"description": "Description in the Tidal lossy format picker"
|
"description": "Description in the lossy format picker"
|
||||||
},
|
},
|
||||||
"downloadLossyMp3": "MP3 320kbps",
|
"downloadLossyMp3": "MP3 320kbps",
|
||||||
"@downloadLossyMp3": {
|
"@downloadLossyMp3": {
|
||||||
"description": "Tidal lossy format option - MP3 320kbps"
|
"description": "Lossy format option - MP3 320kbps"
|
||||||
},
|
},
|
||||||
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
||||||
"@downloadLossyMp3Subtitle": {
|
"@downloadLossyMp3Subtitle": {
|
||||||
"description": "Subtitle for MP3 320kbps Tidal lossy option"
|
"description": "Subtitle for MP3 320kbps lossy option"
|
||||||
},
|
},
|
||||||
"downloadLossyAac": "AAC/M4A 320kbps",
|
"downloadLossyAac": "AAC/M4A 320kbps",
|
||||||
"@downloadLossyAac": {
|
"@downloadLossyAac": {
|
||||||
"description": "Tidal lossy format option - AAC in M4A container at 320kbps"
|
"description": "Lossy format option - AAC in M4A container at 320kbps"
|
||||||
},
|
},
|
||||||
"downloadLossyAacSubtitle": "Best mobile compatibility, M4A container",
|
"downloadLossyAacSubtitle": "Best mobile compatibility, M4A container",
|
||||||
"@downloadLossyAacSubtitle": {
|
"@downloadLossyAacSubtitle": {
|
||||||
"description": "Subtitle for AAC/M4A 320kbps Tidal lossy option"
|
"description": "Subtitle for AAC/M4A 320kbps lossy option"
|
||||||
},
|
},
|
||||||
"downloadLossyOpus256": "Opus 256kbps",
|
"downloadLossyOpus256": "Opus 256kbps",
|
||||||
"@downloadLossyOpus256": {
|
"@downloadLossyOpus256": {
|
||||||
"description": "Tidal lossy format option - Opus 256kbps"
|
"description": "Lossy format option - Opus 256kbps"
|
||||||
},
|
},
|
||||||
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
||||||
"@downloadLossyOpus256Subtitle": {
|
"@downloadLossyOpus256Subtitle": {
|
||||||
"description": "Subtitle for Opus 256kbps Tidal lossy option"
|
"description": "Subtitle for Opus 256kbps lossy option"
|
||||||
},
|
},
|
||||||
"downloadLossyOpus128": "Opus 128kbps",
|
"downloadLossyOpus128": "Opus 128kbps",
|
||||||
"@downloadLossyOpus128": {
|
"@downloadLossyOpus128": {
|
||||||
"description": "Tidal lossy format option - Opus 128kbps"
|
"description": "Lossy format option - Opus 128kbps"
|
||||||
},
|
},
|
||||||
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
||||||
"@downloadLossyOpus128Subtitle": {
|
"@downloadLossyOpus128Subtitle": {
|
||||||
"description": "Subtitle for Opus 128kbps Tidal lossy option"
|
"description": "Subtitle for Opus 128kbps lossy option"
|
||||||
},
|
},
|
||||||
"qualityNote": "Actual quality depends on track availability from the service",
|
"qualityNote": "Actual quality depends on track availability from the service",
|
||||||
"@qualityNote": {
|
"@qualityNote": {
|
||||||
@@ -2054,6 +2107,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Choose how album folders are structured",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||||
@@ -2515,7 +2572,7 @@
|
|||||||
"@libraryAbout": {
|
"@libraryAbout": {
|
||||||
"description": "Section header for about info"
|
"description": "Section header for about info"
|
||||||
},
|
},
|
||||||
"libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, M4A, MP3, Opus, and OGG formats. Metadata is read from file tags when available.",
|
"libraryAboutDescription": "Scans your existing music collection to detect duplicates when downloading. Supports FLAC, ALAC, M4A, MP3, Opus, OGG, WAV, AIFF, and APE formats. Metadata is read from file tags when available.",
|
||||||
"@libraryAboutDescription": {
|
"@libraryAboutDescription": {
|
||||||
"description": "Description of local library feature"
|
"description": "Description of local library feature"
|
||||||
},
|
},
|
||||||
@@ -2741,7 +2798,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from installed download extensions",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -3700,7 +3757,7 @@
|
|||||||
"@lyricsProvidersDescription": {
|
"@lyricsProvidersDescription": {
|
||||||
"description": "Description on the lyrics provider priority page"
|
"description": "Description on the lyrics provider priority page"
|
||||||
},
|
},
|
||||||
"lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.",
|
"lyricsProvidersInfoText": "Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.",
|
||||||
"@lyricsProvidersInfoText": {
|
"@lyricsProvidersInfoText": {
|
||||||
"description": "Info tip on lyrics provider priority page"
|
"description": "Info tip on lyrics provider priority page"
|
||||||
},
|
},
|
||||||
@@ -3754,6 +3811,10 @@
|
|||||||
"@lyricsProviderQqMusicDesc": {
|
"@lyricsProviderQqMusicDesc": {
|
||||||
"description": "Description for QQ Music provider"
|
"description": "Description for QQ Music provider"
|
||||||
},
|
},
|
||||||
|
"lyricsProviderLyricsPlusDesc": "Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)",
|
||||||
|
"@lyricsProviderLyricsPlusDesc": {
|
||||||
|
"description": "Description for LyricsPlus provider"
|
||||||
|
},
|
||||||
"lyricsProviderExtensionDesc": "Extension provider",
|
"lyricsProviderExtensionDesc": "Extension provider",
|
||||||
"@lyricsProviderExtensionDesc": {
|
"@lyricsProviderExtensionDesc": {
|
||||||
"description": "Generic description for extension-based lyrics providers"
|
"description": "Generic description for extension-based lyrics providers"
|
||||||
@@ -3782,6 +3843,169 @@
|
|||||||
"@settingsDonateSubtitle": {
|
"@settingsDonateSubtitle": {
|
||||||
"description": "Subtitle for donate menu item"
|
"description": "Subtitle for donate menu item"
|
||||||
},
|
},
|
||||||
|
"settingsBackup": "Backup & Restore",
|
||||||
|
"@settingsBackup": {
|
||||||
|
"description": "Settings menu item - backup and restore page"
|
||||||
|
},
|
||||||
|
"settingsBackupSubtitle": "Move your library, history and settings to a new device",
|
||||||
|
"@settingsBackupSubtitle": {
|
||||||
|
"description": "Subtitle for backup and restore settings item"
|
||||||
|
},
|
||||||
|
"backupTitle": "Backup & Restore",
|
||||||
|
"@backupTitle": {
|
||||||
|
"description": "App bar title for the backup and restore page"
|
||||||
|
},
|
||||||
|
"backupExportSectionTitle": "Create backup",
|
||||||
|
"@backupExportSectionTitle": {
|
||||||
|
"description": "Section title for the export/backup card"
|
||||||
|
},
|
||||||
|
"backupExportSectionDescription": "Save your settings, download history, liked tracks, wishlist, favorite artists and playlists into a single file you can keep or move to another phone.",
|
||||||
|
"@backupExportSectionDescription": {
|
||||||
|
"description": "Description of what a backup contains"
|
||||||
|
},
|
||||||
|
"backupExportButton": "Create backup file",
|
||||||
|
"@backupExportButton": {
|
||||||
|
"description": "Button to create and share a backup file"
|
||||||
|
},
|
||||||
|
"backupImportSectionTitle": "Restore backup",
|
||||||
|
"@backupImportSectionTitle": {
|
||||||
|
"description": "Section title for the import/restore card"
|
||||||
|
},
|
||||||
|
"backupImportSectionDescription": "Pick a backup file to restore your data. This replaces the current settings, history and library on this device.",
|
||||||
|
"@backupImportSectionDescription": {
|
||||||
|
"description": "Description for the restore action"
|
||||||
|
},
|
||||||
|
"backupImportButton": "Choose backup file",
|
||||||
|
"@backupImportButton": {
|
||||||
|
"description": "Button to pick a backup file to restore"
|
||||||
|
},
|
||||||
|
"backupCreating": "Creating backup...",
|
||||||
|
"@backupCreating": {
|
||||||
|
"description": "Progress text while a backup is being created"
|
||||||
|
},
|
||||||
|
"backupCreated": "Backup created",
|
||||||
|
"@backupCreated": {
|
||||||
|
"description": "Snackbar after a backup file is created"
|
||||||
|
},
|
||||||
|
"backupCreateFailed": "Failed to create backup",
|
||||||
|
"@backupCreateFailed": {
|
||||||
|
"description": "Snackbar when backup creation fails"
|
||||||
|
},
|
||||||
|
"backupEmpty": "There is nothing to back up yet",
|
||||||
|
"@backupEmpty": {
|
||||||
|
"description": "Snackbar when there is no data to back up"
|
||||||
|
},
|
||||||
|
"backupRestoreConfirmTitle": "Restore this backup?",
|
||||||
|
"@backupRestoreConfirmTitle": {
|
||||||
|
"description": "Confirmation dialog title before restoring a backup"
|
||||||
|
},
|
||||||
|
"backupRestoreConfirmMessage": "This will replace your current settings, download history, liked tracks, wishlist and playlists with the contents of the backup. This cannot be undone.",
|
||||||
|
"@backupRestoreConfirmMessage": {
|
||||||
|
"description": "Confirmation dialog message before restoring a backup"
|
||||||
|
},
|
||||||
|
"backupRestoreConfirmButton": "Restore",
|
||||||
|
"@backupRestoreConfirmButton": {
|
||||||
|
"description": "Confirm button to proceed with restore"
|
||||||
|
},
|
||||||
|
"backupRestoring": "Restoring backup...",
|
||||||
|
"@backupRestoring": {
|
||||||
|
"description": "Progress text while restoring a backup"
|
||||||
|
},
|
||||||
|
"backupRestored": "Backup restored successfully",
|
||||||
|
"@backupRestored": {
|
||||||
|
"description": "Snackbar after a successful restore"
|
||||||
|
},
|
||||||
|
"backupRestoreFailed": "Failed to restore backup",
|
||||||
|
"@backupRestoreFailed": {
|
||||||
|
"description": "Snackbar when restore fails"
|
||||||
|
},
|
||||||
|
"backupInvalidFile": "This file is not a valid SpotiFLAC backup",
|
||||||
|
"@backupInvalidFile": {
|
||||||
|
"description": "Snackbar when the chosen file is not a valid backup"
|
||||||
|
},
|
||||||
|
"backupRestoreRestartHint": "Restart the app to make sure every change is applied.",
|
||||||
|
"@backupRestoreRestartHint": {
|
||||||
|
"description": "Hint shown after restoring that an app restart is recommended"
|
||||||
|
},
|
||||||
|
"backupContentsTitle": "Backup contents",
|
||||||
|
"@backupContentsTitle": {
|
||||||
|
"description": "Header above the list summarizing what the backup contains"
|
||||||
|
},
|
||||||
|
"backupContentsSettings": "App settings",
|
||||||
|
"@backupContentsSettings": {
|
||||||
|
"description": "Backup contents row label for settings"
|
||||||
|
},
|
||||||
|
"backupContentsHistory": "{count} history {count, plural, =1{item} other{items}}",
|
||||||
|
"@backupContentsHistory": {
|
||||||
|
"description": "Backup contents row for history count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backupContentsLiked": "{count} liked {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@backupContentsLiked": {
|
||||||
|
"description": "Backup contents row for liked tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backupContentsWishlist": "{count} wishlist {count, plural, =1{track} other{tracks}}",
|
||||||
|
"@backupContentsWishlist": {
|
||||||
|
"description": "Backup contents row for wishlist tracks count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backupContentsPlaylists": "{count, plural, =1{1 playlist} other{{count} playlists}}",
|
||||||
|
"@backupContentsPlaylists": {
|
||||||
|
"description": "Backup contents row for playlist count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backupContentsArtists": "{count, plural, =1{1 favorite artist} other{{count} favorite artists}}",
|
||||||
|
"@backupContentsArtists": {
|
||||||
|
"description": "Backup contents row for favorite artists count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backupContentsExtensions": "{count, plural, =1{1 extension} other{{count} extensions}}",
|
||||||
|
"@backupContentsExtensions": {
|
||||||
|
"description": "Backup contents row for installed extensions count",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backupIncludeSecrets": "Include extension credentials",
|
||||||
|
"@backupIncludeSecrets": {
|
||||||
|
"description": "Toggle to include secret extension settings (tokens, API keys) in the backup"
|
||||||
|
},
|
||||||
|
"backupIncludeSecretsDescription": "Tokens and API keys from extensions will be saved into the backup file. Keep the file private. When off, you re-enter them after restoring.",
|
||||||
|
"@backupIncludeSecretsDescription": {
|
||||||
|
"description": "Explanation for the include-credentials toggle"
|
||||||
|
},
|
||||||
|
"backupExtensionsRestoreFailed": "{count} {count, plural, =1{extension} other{extensions}} could not be reinstalled. Install them manually from the store.",
|
||||||
|
"@backupExtensionsRestoreFailed": {
|
||||||
|
"description": "Snackbar/hint when some extensions failed to reinstall during restore",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"tooltipLoveAll": "Love All",
|
"tooltipLoveAll": "Love All",
|
||||||
"@tooltipLoveAll": {
|
"@tooltipLoveAll": {
|
||||||
"description": "Tooltip for the Love All button on album/playlist screens"
|
"description": "Tooltip for the Love All button on album/playlist screens"
|
||||||
@@ -3938,13 +4162,25 @@
|
|||||||
"@downloadNetworkCompatibilityModeDisabled": {
|
"@downloadNetworkCompatibilityModeDisabled": {
|
||||||
"description": "Subtitle when network compatibility mode is off"
|
"description": "Subtitle when network compatibility mode is off"
|
||||||
},
|
},
|
||||||
"downloadSelectServiceToEnable": "Select Tidal or Qobuz to enable this option",
|
"downloadAllowLocalNetwork": "Allow Local Network Access",
|
||||||
|
"@downloadAllowLocalNetwork": {
|
||||||
|
"description": "Setting title for allowing requests to private/local network targets"
|
||||||
|
},
|
||||||
|
"downloadAllowLocalNetworkEnabled": "Requests to local/private addresses are allowed (for local proxy or custom DNS)",
|
||||||
|
"@downloadAllowLocalNetworkEnabled": {
|
||||||
|
"description": "Subtitle when allow local network access is on"
|
||||||
|
},
|
||||||
|
"downloadAllowLocalNetworkDisabled": "Local/private addresses are blocked for security",
|
||||||
|
"@downloadAllowLocalNetworkDisabled": {
|
||||||
|
"description": "Subtitle when allow local network access is off"
|
||||||
|
},
|
||||||
|
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
|
||||||
"@downloadSelectServiceToEnable": {
|
"@downloadSelectServiceToEnable": {
|
||||||
"description": "Subtitle when quality picker is disabled due to extension service"
|
"description": "Subtitle when quality picker is disabled due to extension service"
|
||||||
},
|
},
|
||||||
"downloadSelectTidalQobuz": "Select Tidal or Qobuz to choose audio quality",
|
"downloadSelectTidalQobuz": "Select a provider with quality options to choose audio quality",
|
||||||
"@downloadSelectTidalQobuz": {
|
"@downloadSelectTidalQobuz": {
|
||||||
"description": "Info shown when a non-built-in service is selected"
|
"description": "Legacy info shown when a provider does not expose quality options"
|
||||||
},
|
},
|
||||||
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
|
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
|
||||||
"@downloadEmbedLyricsDisabled": {
|
"@downloadEmbedLyricsDisabled": {
|
||||||
@@ -4354,7 +4590,7 @@
|
|||||||
},
|
},
|
||||||
"extensionsSearchWith": "Search with {providerName}",
|
"extensionsSearchWith": "Search with {providerName}",
|
||||||
"@extensionsSearchWith": {
|
"@extensionsSearchWith": {
|
||||||
"description": "Extensions page - subtitle for built-in search provider option",
|
"description": "Extensions page - subtitle for default metadata search provider option",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"providerName": {
|
"providerName": {
|
||||||
"type": "String"
|
"type": "String"
|
||||||
@@ -4808,6 +5044,14 @@
|
|||||||
"@queueDownloadCompleted": {
|
"@queueDownloadCompleted": {
|
||||||
"description": "Accessibility label for completed download state in queue"
|
"description": "Accessibility label for completed download state in queue"
|
||||||
},
|
},
|
||||||
|
"queueRateLimitTitle": "Service rate limited",
|
||||||
|
"@queueRateLimitTitle": {
|
||||||
|
"description": "Title shown on a failed queue item when the download service rate limits requests"
|
||||||
|
},
|
||||||
|
"queueRateLimitMessage": "This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.",
|
||||||
|
"@queueRateLimitMessage": {
|
||||||
|
"description": "Explanation shown on a failed queue item when the download service rate limits requests"
|
||||||
|
},
|
||||||
"appearanceSelectAccentColor": "Select accent color {hex}",
|
"appearanceSelectAccentColor": "Select accent color {hex}",
|
||||||
"@appearanceSelectAccentColor": {
|
"@appearanceSelectAccentColor": {
|
||||||
"description": "Accessibility label for picking an accent color",
|
"description": "Accessibility label for picking an accent color",
|
||||||
@@ -5486,5 +5730,479 @@
|
|||||||
"artistReleases": "Releases",
|
"artistReleases": "Releases",
|
||||||
"@artistReleases": {
|
"@artistReleases": {
|
||||||
"description": "Section header for all artist releases"
|
"description": "Section header for all artist releases"
|
||||||
|
},
|
||||||
|
"editMetadataSelectNone": "None",
|
||||||
|
"@editMetadataSelectNone": {
|
||||||
|
"description": "Button to clear selected fields for auto-fill"
|
||||||
|
},
|
||||||
|
"queueRetryAllFailed": "Retry {count} failed",
|
||||||
|
"@queueRetryAllFailed": {
|
||||||
|
"description": "Button to retry every failed download in the queue",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settingsSaveDownloadHistory": "Save download history",
|
||||||
|
"@settingsSaveDownloadHistory": {
|
||||||
|
"description": "Settings switch title for storing completed downloads in history"
|
||||||
|
},
|
||||||
|
"settingsSaveDownloadHistorySubtitle": "Keep completed downloads in history and library views",
|
||||||
|
"@settingsSaveDownloadHistorySubtitle": {
|
||||||
|
"description": "Settings switch subtitle for storing completed downloads in history"
|
||||||
|
},
|
||||||
|
"dialogDisableHistoryTitle": "Turn off download history?",
|
||||||
|
"@dialogDisableHistoryTitle": {
|
||||||
|
"description": "Confirmation dialog title shown before disabling download history"
|
||||||
|
},
|
||||||
|
"dialogDisableHistoryMessage": "Existing history will be cleared. Downloaded files will not be deleted.",
|
||||||
|
"@dialogDisableHistoryMessage": {
|
||||||
|
"description": "Confirmation dialog message shown before disabling download history"
|
||||||
|
},
|
||||||
|
"dialogDisableAndClear": "Turn off and clear",
|
||||||
|
"@dialogDisableAndClear": {
|
||||||
|
"description": "Confirmation action to disable download history and clear existing entries"
|
||||||
|
},
|
||||||
|
"openInOtherServices": "Open in Other Services",
|
||||||
|
"@openInOtherServices": {
|
||||||
|
"description": "Title and tooltip for finding the current collection in other services"
|
||||||
|
},
|
||||||
|
"shareSheetNoExtensions": "No other compatible services",
|
||||||
|
"@shareSheetNoExtensions": {
|
||||||
|
"description": "Empty state when no extensions can be searched for cross-service links"
|
||||||
|
},
|
||||||
|
"shareSheetNotFound": "Not found",
|
||||||
|
"@shareSheetNotFound": {
|
||||||
|
"description": "Cross-service share sheet row subtitle when a service has no match"
|
||||||
|
},
|
||||||
|
"shareSheetCopyLink": "Copy Link",
|
||||||
|
"@shareSheetCopyLink": {
|
||||||
|
"description": "Tooltip for copying a cross-service link"
|
||||||
|
},
|
||||||
|
"shareSheetLinkCopied": "{service} link copied",
|
||||||
|
"@shareSheetLinkCopied": {
|
||||||
|
"description": "Snackbar after copying a cross-service link",
|
||||||
|
"placeholders": {
|
||||||
|
"service": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"libraryPlayback": "Playback",
|
||||||
|
"@libraryPlayback": {
|
||||||
|
"description": "Section header for playback settings in library settings"
|
||||||
|
},
|
||||||
|
"libraryExternalPlayer": "External player",
|
||||||
|
"@libraryExternalPlayer": {
|
||||||
|
"description": "Setting option to use an external music player"
|
||||||
|
},
|
||||||
|
"libraryExternalPlayerSubtitle": "Recommended for listening, best quality, gapless playback, EQ, and wider format support",
|
||||||
|
"@libraryExternalPlayerSubtitle": {
|
||||||
|
"description": "Subtitle for external player option"
|
||||||
|
},
|
||||||
|
"libraryBuiltInPreviewPlayer": "Built-in preview player",
|
||||||
|
"@libraryBuiltInPreviewPlayer": {
|
||||||
|
"description": "Setting option to use the built-in preview player"
|
||||||
|
},
|
||||||
|
"libraryBuiltInPreviewPlayerSubtitle": "Only for quick local previews inside SpotiFLAC Mobile, not recommended for regular listening",
|
||||||
|
"@libraryBuiltInPreviewPlayerSubtitle": {
|
||||||
|
"description": "Subtitle for built-in preview player option"
|
||||||
|
},
|
||||||
|
"libraryBuiltInPlayerInfo": "The built-in player is a preview tool for checking local tracks quickly. Use an external music player for actual listening.",
|
||||||
|
"@libraryBuiltInPlayerInfo": {
|
||||||
|
"description": "Info note explaining the built-in player is for previews only"
|
||||||
|
},
|
||||||
|
"nowPlayingTitle": "Now Playing",
|
||||||
|
"@nowPlayingTitle": {
|
||||||
|
"description": "Title for the now playing screen"
|
||||||
|
},
|
||||||
|
"nowPlayingNothingPlaying": "Nothing is playing",
|
||||||
|
"@nowPlayingNothingPlaying": {
|
||||||
|
"description": "Empty state when no track is currently playing"
|
||||||
|
},
|
||||||
|
"nowPlayingMinimize": "Minimize",
|
||||||
|
"@nowPlayingMinimize": {
|
||||||
|
"description": "Tooltip for minimizing the now playing screen"
|
||||||
|
},
|
||||||
|
"nowPlayingUpNext": "Up next",
|
||||||
|
"@nowPlayingUpNext": {
|
||||||
|
"description": "Title for the playback queue sheet"
|
||||||
|
},
|
||||||
|
"nowPlayingDetails": "Details",
|
||||||
|
"@nowPlayingDetails": {
|
||||||
|
"description": "Menu item and section title for track metadata details"
|
||||||
|
},
|
||||||
|
"nowPlayingOpenInExternalPlayer": "Open in external player",
|
||||||
|
"@nowPlayingOpenInExternalPlayer": {
|
||||||
|
"description": "Menu item to open the current track in an external player"
|
||||||
|
},
|
||||||
|
"nowPlayingTabPlayer": "Player",
|
||||||
|
"@nowPlayingTabPlayer": {
|
||||||
|
"description": "Tab label for the player view"
|
||||||
|
},
|
||||||
|
"nowPlayingTabLyrics": "Lyrics",
|
||||||
|
"@nowPlayingTabLyrics": {
|
||||||
|
"description": "Tab label for the lyrics view"
|
||||||
|
},
|
||||||
|
"nowPlayingNoLyrics": "No lyrics in this file",
|
||||||
|
"@nowPlayingNoLyrics": {
|
||||||
|
"description": "Empty state when the playing file has no embedded lyrics"
|
||||||
|
},
|
||||||
|
"nowPlayingLibraryEmpty": "Your library is empty",
|
||||||
|
"@nowPlayingLibraryEmpty": {
|
||||||
|
"description": "Snackbar when shuffle library is requested but library has no tracks"
|
||||||
|
},
|
||||||
|
"nowPlayingShuffleLibraryFailed": "Could not shuffle library: {error}",
|
||||||
|
"@nowPlayingShuffleLibraryFailed": {
|
||||||
|
"description": "Snackbar when shuffling the library fails",
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nowPlayingShuffleOn": "Shuffle on",
|
||||||
|
"@nowPlayingShuffleOn": {
|
||||||
|
"description": "Tooltip when shuffle mode is enabled"
|
||||||
|
},
|
||||||
|
"nowPlayingPlayInOrder": "Play in order",
|
||||||
|
"@nowPlayingPlayInOrder": {
|
||||||
|
"description": "Tooltip when shuffle mode is disabled"
|
||||||
|
},
|
||||||
|
"nowPlayingShuffleLibrary": "Shuffle library",
|
||||||
|
"@nowPlayingShuffleLibrary": {
|
||||||
|
"description": "Button label to shuffle and play the entire local library"
|
||||||
|
},
|
||||||
|
"nowPlayingQueueEmpty": "Queue is empty",
|
||||||
|
"@nowPlayingQueueEmpty": {
|
||||||
|
"description": "Empty state when the playback queue has no items"
|
||||||
|
},
|
||||||
|
"nowPlayingNoMetadata": "No metadata available",
|
||||||
|
"@nowPlayingNoMetadata": {
|
||||||
|
"description": "Empty state when track metadata cannot be loaded"
|
||||||
|
},
|
||||||
|
"announcementUnableToOpenLink": "Unable to open link. Please try again.",
|
||||||
|
"@announcementUnableToOpenLink": {
|
||||||
|
"description": "Snackbar shown when an announcement CTA link cannot be opened"
|
||||||
|
},
|
||||||
|
"trackConvertLosslessOutputWithCap": "Lossless output with {quality} cap",
|
||||||
|
"@trackConvertLosslessOutputWithCap": {
|
||||||
|
"description": "Hint shown when lossless conversion will cap bit depth or sample rate",
|
||||||
|
"placeholders": {
|
||||||
|
"quality": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertConfirmMessageLosslessCapped": "Convert from {sourceFormat} to {targetFormat} ({quality})?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original file will be deleted after conversion.",
|
||||||
|
"@trackConvertConfirmMessageLosslessCapped": {
|
||||||
|
"description": "Confirmation dialog message for capped lossless conversion of a single file",
|
||||||
|
"placeholders": {
|
||||||
|
"sourceFormat": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"targetFormat": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"selectionBatchConvertConfirmMessageLosslessCapped": "Convert {count} {count, plural, =1{track} other{tracks}} to {format} ({quality})?\n\nThe output stays in a lossless codec, but bit depth/sample rate will be capped. Original files will be deleted after conversion.",
|
||||||
|
"@selectionBatchConvertConfirmMessageLosslessCapped": {
|
||||||
|
"description": "Confirmation dialog message for capped lossless batch conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertActionLabelLossless": "{sourceFormat} → {targetFormat} ({quality})",
|
||||||
|
"@trackConvertActionLabelLossless": {
|
||||||
|
"description": "Convert button label for lossless conversion with quality cap",
|
||||||
|
"placeholders": {
|
||||||
|
"sourceFormat": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"targetFormat": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"trackConvertActionLabelLossy": "{sourceFormat} → {targetFormat} @ {bitrate}",
|
||||||
|
"@trackConvertActionLabelLossy": {
|
||||||
|
"description": "Convert button label for lossy conversion",
|
||||||
|
"placeholders": {
|
||||||
|
"sourceFormat": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"targetFormat": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"bitrate": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"aboutPaxsenixSubtitle": "Lyrics proxy for Musixmatch, Netease, Apple Music, QQ Music, Spotify, Deezer, YouTube, Kugou, and Genius",
|
||||||
|
"@aboutPaxsenixSubtitle": {
|
||||||
|
"description": "Subtitle for Paxsenix special thanks entry on the about page"
|
||||||
|
},
|
||||||
|
"snackbarPlayingNext": "Playing next",
|
||||||
|
"@snackbarPlayingNext": {
|
||||||
|
"description": "Snackbar when a track is inserted as the next queue item"
|
||||||
|
},
|
||||||
|
"snackbarAddedToQueueGeneric": "Added to queue",
|
||||||
|
"@snackbarAddedToQueueGeneric": {
|
||||||
|
"description": "Snackbar when a track is added to the playback queue without naming it"
|
||||||
|
},
|
||||||
|
"selectionDeletePlaylistsCount": "Delete {count} {count, plural, =1{playlist} other{playlists}}",
|
||||||
|
"@selectionDeletePlaylistsCount": {
|
||||||
|
"description": "Button label for deleting multiple selected playlists",
|
||||||
|
"placeholders": {
|
||||||
|
"count": {
|
||||||
|
"type": "int"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actionShuffle": "Shuffle",
|
||||||
|
"@actionShuffle": {
|
||||||
|
"description": "Tooltip for shuffle playback action"
|
||||||
|
},
|
||||||
|
"downloadPrimaryArtistOnlyOn": "Primary only: On",
|
||||||
|
"@downloadPrimaryArtistOnlyOn": {
|
||||||
|
"description": "Status label when primary-artist-only folder naming is enabled"
|
||||||
|
},
|
||||||
|
"downloadPrimaryArtistOnlyOff": "Primary only: Off",
|
||||||
|
"@downloadPrimaryArtistOnlyOff": {
|
||||||
|
"description": "Status label when primary-artist-only folder naming is disabled"
|
||||||
|
},
|
||||||
|
"downloadAlbumArtistMetadataPrimaryOnly": "Album Artist metadata: Primary only",
|
||||||
|
"@downloadAlbumArtistMetadataPrimaryOnly": {
|
||||||
|
"description": "Status label when album-artist folder filtering uses primary artist only"
|
||||||
|
},
|
||||||
|
"downloadAlbumArtistMetadataFull": "Album Artist metadata: Full",
|
||||||
|
"@downloadAlbumArtistMetadataFull": {
|
||||||
|
"description": "Status label when album-artist folder filtering uses full metadata"
|
||||||
|
},
|
||||||
|
"trackConvertOriginal": "Original",
|
||||||
|
"@trackConvertOriginal": {
|
||||||
|
"description": "Label for keeping original bit depth or sample rate during conversion"
|
||||||
|
},
|
||||||
|
"trackConvertOriginalQuality": "Original quality",
|
||||||
|
"@trackConvertOriginalQuality": {
|
||||||
|
"description": "Label when no bit depth or sample rate cap is applied during lossless conversion"
|
||||||
|
},
|
||||||
|
"trackConvertLosslessSuffix": "Lossless",
|
||||||
|
"@trackConvertLosslessSuffix": {
|
||||||
|
"description": "Suffix used in converted lossless quality labels"
|
||||||
|
},
|
||||||
|
"trackConvertDithering": "Dithering",
|
||||||
|
"@trackConvertDithering": {
|
||||||
|
"description": "Section label for lossless conversion dithering options"
|
||||||
|
},
|
||||||
|
"trackConvertResampler": "Resampler",
|
||||||
|
"@trackConvertResampler": {
|
||||||
|
"description": "Section label for lossless conversion resampler options"
|
||||||
|
},
|
||||||
|
"trackConvertDitherNone": "None",
|
||||||
|
"@trackConvertDitherNone": {
|
||||||
|
"description": "Lossless conversion dither option with no dithering applied"
|
||||||
|
},
|
||||||
|
"trackConvertDitherTriangular": "TPDF",
|
||||||
|
"@trackConvertDitherTriangular": {
|
||||||
|
"description": "Lossless conversion triangular probability density function dither option"
|
||||||
|
},
|
||||||
|
"trackConvertDitherTriangularHp": "Triangular HP",
|
||||||
|
"@trackConvertDitherTriangularHp": {
|
||||||
|
"description": "Lossless conversion high-pass triangular dither option"
|
||||||
|
},
|
||||||
|
"trackConvertResamplerSwr": "SWR",
|
||||||
|
"@trackConvertResamplerSwr": {
|
||||||
|
"description": "Lossless conversion default FFmpeg swresample resampler option"
|
||||||
|
},
|
||||||
|
"trackConvertResamplerSoxr": "SoXr",
|
||||||
|
"@trackConvertResamplerSoxr": {
|
||||||
|
"description": "Lossless conversion SoX resampler option"
|
||||||
|
},
|
||||||
|
"updateSeeReleaseNotes": "See release notes for details.",
|
||||||
|
"@updateSeeReleaseNotes": {
|
||||||
|
"description": "Fallback changelog text when release notes cannot be parsed"
|
||||||
|
},
|
||||||
|
"unknownTitle": "Unknown title",
|
||||||
|
"@unknownTitle": {
|
||||||
|
"description": "Fallback track title when metadata is missing"
|
||||||
|
},
|
||||||
|
"trackPlayNext": "Play next",
|
||||||
|
"@trackPlayNext": {
|
||||||
|
"description": "Menu action to play a track as the next queue item"
|
||||||
|
},
|
||||||
|
"trackAddToQueue": "Add to queue",
|
||||||
|
"@trackAddToQueue": {
|
||||||
|
"description": "Menu action to add a track to the playback queue"
|
||||||
|
},
|
||||||
|
"snackbarExtensionInstalledEnable": "{extensionName} installed. Enable it in Settings > Extensions",
|
||||||
|
"@snackbarExtensionInstalledEnable": {
|
||||||
|
"description": "Snackbar after installing an extension from the repo tab",
|
||||||
|
"placeholders": {
|
||||||
|
"extensionName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"snackbarExtensionUpdatedVersion": "{extensionName} updated to v{version}",
|
||||||
|
"@snackbarExtensionUpdatedVersion": {
|
||||||
|
"description": "Snackbar after updating an extension from the repo tab",
|
||||||
|
"placeholders": {
|
||||||
|
"extensionName": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"snackbarFailedToInstallNamed": "Failed to install {extensionName}",
|
||||||
|
"@snackbarFailedToInstallNamed": {
|
||||||
|
"description": "Snackbar when extension install fails in the repo tab",
|
||||||
|
"placeholders": {
|
||||||
|
"extensionName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"snackbarFailedToUpdateNamed": "Failed to update {extensionName}",
|
||||||
|
"@snackbarFailedToUpdateNamed": {
|
||||||
|
"description": "Snackbar when extension update fails in the repo tab",
|
||||||
|
"placeholders": {
|
||||||
|
"extensionName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"releaseTypeEp": "EP",
|
||||||
|
"@releaseTypeEp": {
|
||||||
|
"description": "Badge label for EP releases"
|
||||||
|
},
|
||||||
|
"releaseTypeSingle": "Single",
|
||||||
|
"@releaseTypeSingle": {
|
||||||
|
"description": "Badge label for single releases"
|
||||||
|
},
|
||||||
|
"trackCoverOnline": "Online cover",
|
||||||
|
"@trackCoverOnline": {
|
||||||
|
"description": "Label shown when metadata autofill downloaded cover art from the internet"
|
||||||
|
},
|
||||||
|
"regionCountryUS": "United States",
|
||||||
|
"@regionCountryUS": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryGB": "United Kingdom",
|
||||||
|
"@regionCountryGB": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryFR": "France",
|
||||||
|
"@regionCountryFR": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryDE": "Germany",
|
||||||
|
"@regionCountryDE": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryJP": "Japan",
|
||||||
|
"@regionCountryJP": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryKR": "South Korea",
|
||||||
|
"@regionCountryKR": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryIN": "India",
|
||||||
|
"@regionCountryIN": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryID": "Indonesia",
|
||||||
|
"@regionCountryID": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryBR": "Brazil",
|
||||||
|
"@regionCountryBR": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryMX": "Mexico",
|
||||||
|
"@regionCountryMX": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryAU": "Australia",
|
||||||
|
"@regionCountryAU": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryCA": "Canada",
|
||||||
|
"@regionCountryCA": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"regionCountryXK": "Kosovo",
|
||||||
|
"@regionCountryXK": {
|
||||||
|
"description": "Country name for SongLink region picker"
|
||||||
|
},
|
||||||
|
"extensionVerificationBrowserTitle": "Verification browser",
|
||||||
|
"@extensionVerificationBrowserTitle": {
|
||||||
|
"description": "Settings option title for extension verification browser preference"
|
||||||
|
},
|
||||||
|
"extensionVerificationBrowserSubtitleExternal": "Open challenges in the default browser first",
|
||||||
|
"@extensionVerificationBrowserSubtitleExternal": {
|
||||||
|
"description": "Subtitle when external browser is preferred for extension verification"
|
||||||
|
},
|
||||||
|
"extensionVerificationBrowserSubtitleInApp": "Open challenges in the in-app browser first",
|
||||||
|
"@extensionVerificationBrowserSubtitleInApp": {
|
||||||
|
"description": "Subtitle when in-app browser is preferred for extension verification"
|
||||||
|
},
|
||||||
|
"extensionVerificationBrowserExternal": "External",
|
||||||
|
"@extensionVerificationBrowserExternal": {
|
||||||
|
"description": "Chip label for external browser verification mode"
|
||||||
|
},
|
||||||
|
"extensionVerificationBrowserInApp": "In-app",
|
||||||
|
"@extensionVerificationBrowserInApp": {
|
||||||
|
"description": "Chip label for in-app browser verification mode"
|
||||||
|
},
|
||||||
|
"extensionVerificationHelpTitleManual": "Open verification manually",
|
||||||
|
"@extensionVerificationHelpTitleManual": {
|
||||||
|
"description": "Dialog title when automatic browser launch for verification fails"
|
||||||
|
},
|
||||||
|
"extensionVerificationHelpTitleWaiting": "Verification still waiting",
|
||||||
|
"@extensionVerificationHelpTitleWaiting": {
|
||||||
|
"description": "Dialog title when verification is taking longer than expected"
|
||||||
|
},
|
||||||
|
"extensionVerificationHelpMessageManual": "SpotiFLAC Mobile could not open the browser automatically. Open this link in your browser, or copy it manually.",
|
||||||
|
"@extensionVerificationHelpMessageManual": {
|
||||||
|
"description": "Dialog message when automatic browser launch for verification fails"
|
||||||
|
},
|
||||||
|
"extensionVerificationHelpMessageWaiting": "If the browser did not open, or verification finished but did not return to SpotiFLAC Mobile, open this link again or copy it manually.",
|
||||||
|
"@extensionVerificationHelpMessageWaiting": {
|
||||||
|
"description": "Dialog message when verification may need manual browser help"
|
||||||
|
},
|
||||||
|
"extensionVerificationClose": "Close",
|
||||||
|
"@extensionVerificationClose": {
|
||||||
|
"description": "Button to dismiss the extension verification help dialog"
|
||||||
|
},
|
||||||
|
"extensionVerificationCopyLink": "Copy link",
|
||||||
|
"@extensionVerificationCopyLink": {
|
||||||
|
"description": "Button to copy the extension verification URL"
|
||||||
|
},
|
||||||
|
"extensionVerificationLinkCopied": "Verification link copied",
|
||||||
|
"@extensionVerificationLinkCopied": {
|
||||||
|
"description": "Snackbar after copying the extension verification URL"
|
||||||
|
},
|
||||||
|
"extensionVerificationOpenBrowser": "Open browser",
|
||||||
|
"@extensionVerificationOpenBrowser": {
|
||||||
|
"description": "Button to open the extension verification URL in a browser"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-50
@@ -142,9 +142,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
|
"optionsSwitchBack": "Choose the default search provider to switch back from an extension",
|
||||||
"@optionsSwitchBack": {
|
"@optionsSwitchBack": {
|
||||||
"description": "Hint to switch back to built-in providers"
|
"description": "Hint to switch back from extension search"
|
||||||
},
|
},
|
||||||
"optionsAutoFallback": "Auto Fallback",
|
"optionsAutoFallback": "Auto Fallback",
|
||||||
"@optionsAutoFallback": {
|
"@optionsAutoFallback": {
|
||||||
@@ -156,15 +156,15 @@
|
|||||||
},
|
},
|
||||||
"optionsUseExtensionProviders": "Use Extension Providers",
|
"optionsUseExtensionProviders": "Use Extension Providers",
|
||||||
"@optionsUseExtensionProviders": {
|
"@optionsUseExtensionProviders": {
|
||||||
"description": "Enable extension download providers"
|
"description": "Legacy setting label for extension download providers"
|
||||||
},
|
},
|
||||||
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
|
"optionsUseExtensionProvidersOn": "Extension providers are enabled",
|
||||||
"@optionsUseExtensionProvidersOn": {
|
"@optionsUseExtensionProvidersOn": {
|
||||||
"description": "Status when extension providers enabled"
|
"description": "Status when extension providers enabled"
|
||||||
},
|
},
|
||||||
"optionsUseExtensionProvidersOff": "Using built-in providers only",
|
"optionsUseExtensionProvidersOff": "Extension providers are required",
|
||||||
"@optionsUseExtensionProvidersOff": {
|
"@optionsUseExtensionProvidersOff": {
|
||||||
"description": "Status when extension providers disabled"
|
"description": "Legacy status when extension providers would be disabled"
|
||||||
},
|
},
|
||||||
"optionsEmbedLyrics": "Embed Lyrics",
|
"optionsEmbedLyrics": "Embed Lyrics",
|
||||||
"@optionsEmbedLyrics": {
|
"@optionsEmbedLyrics": {
|
||||||
@@ -182,27 +182,6 @@
|
|||||||
"@optionsMaxQualityCoverSubtitle": {
|
"@optionsMaxQualityCoverSubtitle": {
|
||||||
"description": "Subtitle for max quality cover"
|
"description": "Subtitle for max quality cover"
|
||||||
},
|
},
|
||||||
"optionsConcurrentDownloads": "Concurrent Downloads",
|
|
||||||
"@optionsConcurrentDownloads": {
|
|
||||||
"description": "Number of parallel downloads"
|
|
||||||
},
|
|
||||||
"optionsConcurrentSequential": "Sequential (1 at a time)",
|
|
||||||
"@optionsConcurrentSequential": {
|
|
||||||
"description": "Download one at a time"
|
|
||||||
},
|
|
||||||
"optionsConcurrentParallel": "{count} parallel downloads",
|
|
||||||
"@optionsConcurrentParallel": {
|
|
||||||
"description": "Multiple parallel downloads",
|
|
||||||
"placeholders": {
|
|
||||||
"count": {
|
|
||||||
"type": "int"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
|
|
||||||
"@optionsConcurrentWarning": {
|
|
||||||
"description": "Warning about rate limits"
|
|
||||||
},
|
|
||||||
"optionsExtensionStore": "Extension Store",
|
"optionsExtensionStore": "Extension Store",
|
||||||
"@optionsExtensionStore": {
|
"@optionsExtensionStore": {
|
||||||
"description": "Show/hide store tab"
|
"description": "Show/hide store tab"
|
||||||
@@ -390,11 +369,11 @@
|
|||||||
"@aboutVersion": {
|
"@aboutVersion": {
|
||||||
"description": "Version info label"
|
"description": "Version info label"
|
||||||
},
|
},
|
||||||
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
|
"aboutBinimumDesc": "The creator of QQDL & HiFi API. This project helped shape lossless download support.",
|
||||||
"@aboutBinimumDesc": {
|
"@aboutBinimumDesc": {
|
||||||
"description": "Credit description for binimum"
|
"description": "Credit description for binimum"
|
||||||
},
|
},
|
||||||
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
|
"aboutSachinsenalDesc": "The original HiFi project creator. A foundation for lossless-source integration.",
|
||||||
"@aboutSachinsenalDesc": {
|
"@aboutSachinsenalDesc": {
|
||||||
"description": "Credit description for sachinsenal0x64"
|
"description": "Credit description for sachinsenal0x64"
|
||||||
},
|
},
|
||||||
@@ -999,9 +978,9 @@
|
|||||||
"@providerPriorityInfo": {
|
"@providerPriorityInfo": {
|
||||||
"description": "Info tip about fallback behavior"
|
"description": "Info tip about fallback behavior"
|
||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Legacy",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
"description": "Legacy label retained for old generated localization compatibility"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1394,11 +1373,11 @@
|
|||||||
"@storeClearFilters": {
|
"@storeClearFilters": {
|
||||||
"description": "Button to clear all filters"
|
"description": "Button to clear all filters"
|
||||||
},
|
},
|
||||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
"extensionDefaultProvider": "Default Search",
|
||||||
"@extensionDefaultProvider": {
|
"@extensionDefaultProvider": {
|
||||||
"description": "Default search provider option"
|
"description": "Default search provider option"
|
||||||
},
|
},
|
||||||
"extensionDefaultProviderSubtitle": "Use built-in search",
|
"extensionDefaultProviderSubtitle": "Use the default metadata search",
|
||||||
"@extensionDefaultProviderSubtitle": {
|
"@extensionDefaultProviderSubtitle": {
|
||||||
"description": "Subtitle for default provider"
|
"description": "Subtitle for default provider"
|
||||||
},
|
},
|
||||||
@@ -1613,6 +1592,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Choose how album folders are structured",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadSelectQuality": "Select Quality",
|
"downloadSelectQuality": "Select Quality",
|
||||||
"@downloadSelectQuality": {
|
"@downloadSelectQuality": {
|
||||||
"description": "Dialog title - choose audio quality"
|
"description": "Dialog title - choose audio quality"
|
||||||
@@ -2071,43 +2054,43 @@
|
|||||||
},
|
},
|
||||||
"downloadLossy320": "Lossy 320kbps",
|
"downloadLossy320": "Lossy 320kbps",
|
||||||
"@downloadLossy320": {
|
"@downloadLossy320": {
|
||||||
"description": "Quality option label for Tidal lossy 320kbps"
|
"description": "Quality option label for lossy 320kbps"
|
||||||
},
|
},
|
||||||
"downloadLossyFormat": "Lossy Format",
|
"downloadLossyFormat": "Lossy Format",
|
||||||
"@downloadLossyFormat": {
|
"@downloadLossyFormat": {
|
||||||
"description": "Setting title to pick output format for Tidal lossy downloads"
|
"description": "Setting title to pick output format for lossy downloads"
|
||||||
},
|
},
|
||||||
"downloadLossy320Format": "Lossy 320kbps Format",
|
"downloadLossy320Format": "Lossy 320kbps Format",
|
||||||
"@downloadLossy320Format": {
|
"@downloadLossy320Format": {
|
||||||
"description": "Title of the Tidal lossy format picker bottom sheet"
|
"description": "Title of the lossy format picker bottom sheet"
|
||||||
},
|
},
|
||||||
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
|
"downloadLossy320FormatDesc": "Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.",
|
||||||
"@downloadLossy320FormatDesc": {
|
"@downloadLossy320FormatDesc": {
|
||||||
"description": "Description in the Tidal lossy format picker"
|
"description": "Description in the lossy format picker"
|
||||||
},
|
},
|
||||||
"downloadLossyMp3": "MP3 320kbps",
|
"downloadLossyMp3": "MP3 320kbps",
|
||||||
"@downloadLossyMp3": {
|
"@downloadLossyMp3": {
|
||||||
"description": "Tidal lossy format option - MP3 320kbps"
|
"description": "lossy format option - MP3 320kbps"
|
||||||
},
|
},
|
||||||
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
||||||
"@downloadLossyMp3Subtitle": {
|
"@downloadLossyMp3Subtitle": {
|
||||||
"description": "Subtitle for MP3 320kbps Tidal lossy option"
|
"description": "Subtitle for MP3 320kbps lossy option"
|
||||||
},
|
},
|
||||||
"downloadLossyOpus256": "Opus 256kbps",
|
"downloadLossyOpus256": "Opus 256kbps",
|
||||||
"@downloadLossyOpus256": {
|
"@downloadLossyOpus256": {
|
||||||
"description": "Tidal lossy format option - Opus 256kbps"
|
"description": "lossy format option - Opus 256kbps"
|
||||||
},
|
},
|
||||||
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
||||||
"@downloadLossyOpus256Subtitle": {
|
"@downloadLossyOpus256Subtitle": {
|
||||||
"description": "Subtitle for Opus 256kbps Tidal lossy option"
|
"description": "Subtitle for Opus 256kbps lossy option"
|
||||||
},
|
},
|
||||||
"downloadLossyOpus128": "Opus 128kbps",
|
"downloadLossyOpus128": "Opus 128kbps",
|
||||||
"@downloadLossyOpus128": {
|
"@downloadLossyOpus128": {
|
||||||
"description": "Tidal lossy format option - Opus 128kbps"
|
"description": "lossy format option - Opus 128kbps"
|
||||||
},
|
},
|
||||||
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
||||||
"@downloadLossyOpus128Subtitle": {
|
"@downloadLossyOpus128Subtitle": {
|
||||||
"description": "Subtitle for Opus 128kbps Tidal lossy option"
|
"description": "Subtitle for Opus 128kbps lossy option"
|
||||||
},
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
@@ -2697,7 +2680,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from installed download extensions",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -3600,7 +3583,7 @@
|
|||||||
"@lyricsProvidersDescription": {
|
"@lyricsProvidersDescription": {
|
||||||
"description": "Description on the lyrics provider priority page"
|
"description": "Description on the lyrics provider priority page"
|
||||||
},
|
},
|
||||||
"lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.",
|
"lyricsProvidersInfoText": "Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.",
|
||||||
"@lyricsProvidersInfoText": {
|
"@lyricsProvidersInfoText": {
|
||||||
"description": "Info tip on lyrics provider priority page"
|
"description": "Info tip on lyrics provider priority page"
|
||||||
},
|
},
|
||||||
@@ -3838,13 +3821,13 @@
|
|||||||
"@downloadNetworkCompatibilityModeDisabled": {
|
"@downloadNetworkCompatibilityModeDisabled": {
|
||||||
"description": "Subtitle when network compatibility mode is off"
|
"description": "Subtitle when network compatibility mode is off"
|
||||||
},
|
},
|
||||||
"downloadSelectServiceToEnable": "Select Tidal or Qobuz to enable this option",
|
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
|
||||||
"@downloadSelectServiceToEnable": {
|
"@downloadSelectServiceToEnable": {
|
||||||
"description": "Subtitle when quality picker is disabled due to extension service"
|
"description": "Subtitle when quality picker is disabled due to extension service"
|
||||||
},
|
},
|
||||||
"downloadSelectTidalQobuz": "Select Tidal or Qobuz to choose audio quality",
|
"downloadSelectTidalQobuz": "Select a provider with quality options to choose audio quality",
|
||||||
"@downloadSelectTidalQobuz": {
|
"@downloadSelectTidalQobuz": {
|
||||||
"description": "Info shown when a non-built-in service is selected"
|
"description": "Legacy info shown when a provider does not expose quality options"
|
||||||
},
|
},
|
||||||
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
|
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
|
||||||
"@downloadEmbedLyricsDisabled": {
|
"@downloadEmbedLyricsDisabled": {
|
||||||
@@ -4206,7 +4189,7 @@
|
|||||||
},
|
},
|
||||||
"extensionsSearchWith": "Search with {providerName}",
|
"extensionsSearchWith": "Search with {providerName}",
|
||||||
"@extensionsSearchWith": {
|
"@extensionsSearchWith": {
|
||||||
"description": "Extensions page - subtitle for built-in search provider option",
|
"description": "Extensions page - subtitle for default metadata search provider option",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"providerName": {
|
"providerName": {
|
||||||
"type": "String"
|
"type": "String"
|
||||||
|
|||||||
+1396
-440
File diff suppressed because it is too large
Load Diff
+1855
-899
File diff suppressed because it is too large
Load Diff
+1053
-97
File diff suppressed because it is too large
Load Diff
+1551
-117
File diff suppressed because it is too large
Load Diff
+1052
-96
File diff suppressed because it is too large
Load Diff
+1058
-102
File diff suppressed because it is too large
Load Diff
+1054
-98
File diff suppressed because it is too large
Load Diff
+33
-50
@@ -142,9 +142,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
|
"optionsSwitchBack": "Choose the default search provider to switch back from an extension",
|
||||||
"@optionsSwitchBack": {
|
"@optionsSwitchBack": {
|
||||||
"description": "Hint to switch back to built-in providers"
|
"description": "Hint to switch back from extension search"
|
||||||
},
|
},
|
||||||
"optionsAutoFallback": "Auto Fallback",
|
"optionsAutoFallback": "Auto Fallback",
|
||||||
"@optionsAutoFallback": {
|
"@optionsAutoFallback": {
|
||||||
@@ -156,15 +156,15 @@
|
|||||||
},
|
},
|
||||||
"optionsUseExtensionProviders": "Use Extension Providers",
|
"optionsUseExtensionProviders": "Use Extension Providers",
|
||||||
"@optionsUseExtensionProviders": {
|
"@optionsUseExtensionProviders": {
|
||||||
"description": "Enable extension download providers"
|
"description": "Legacy setting label for extension download providers"
|
||||||
},
|
},
|
||||||
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
|
"optionsUseExtensionProvidersOn": "Extension providers are enabled",
|
||||||
"@optionsUseExtensionProvidersOn": {
|
"@optionsUseExtensionProvidersOn": {
|
||||||
"description": "Status when extension providers enabled"
|
"description": "Status when extension providers enabled"
|
||||||
},
|
},
|
||||||
"optionsUseExtensionProvidersOff": "Using built-in providers only",
|
"optionsUseExtensionProvidersOff": "Extension providers are required",
|
||||||
"@optionsUseExtensionProvidersOff": {
|
"@optionsUseExtensionProvidersOff": {
|
||||||
"description": "Status when extension providers disabled"
|
"description": "Legacy status when extension providers would be disabled"
|
||||||
},
|
},
|
||||||
"optionsEmbedLyrics": "Embed Lyrics",
|
"optionsEmbedLyrics": "Embed Lyrics",
|
||||||
"@optionsEmbedLyrics": {
|
"@optionsEmbedLyrics": {
|
||||||
@@ -182,27 +182,6 @@
|
|||||||
"@optionsMaxQualityCoverSubtitle": {
|
"@optionsMaxQualityCoverSubtitle": {
|
||||||
"description": "Subtitle for max quality cover"
|
"description": "Subtitle for max quality cover"
|
||||||
},
|
},
|
||||||
"optionsConcurrentDownloads": "Concurrent Downloads",
|
|
||||||
"@optionsConcurrentDownloads": {
|
|
||||||
"description": "Number of parallel downloads"
|
|
||||||
},
|
|
||||||
"optionsConcurrentSequential": "Sequential (1 at a time)",
|
|
||||||
"@optionsConcurrentSequential": {
|
|
||||||
"description": "Download one at a time"
|
|
||||||
},
|
|
||||||
"optionsConcurrentParallel": "{count} parallel downloads",
|
|
||||||
"@optionsConcurrentParallel": {
|
|
||||||
"description": "Multiple parallel downloads",
|
|
||||||
"placeholders": {
|
|
||||||
"count": {
|
|
||||||
"type": "int"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
|
|
||||||
"@optionsConcurrentWarning": {
|
|
||||||
"description": "Warning about rate limits"
|
|
||||||
},
|
|
||||||
"optionsExtensionStore": "Extension Store",
|
"optionsExtensionStore": "Extension Store",
|
||||||
"@optionsExtensionStore": {
|
"@optionsExtensionStore": {
|
||||||
"description": "Show/hide store tab"
|
"description": "Show/hide store tab"
|
||||||
@@ -390,11 +369,11 @@
|
|||||||
"@aboutVersion": {
|
"@aboutVersion": {
|
||||||
"description": "Version info label"
|
"description": "Version info label"
|
||||||
},
|
},
|
||||||
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
|
"aboutBinimumDesc": "The creator of QQDL & HiFi API. This project helped shape lossless download support.",
|
||||||
"@aboutBinimumDesc": {
|
"@aboutBinimumDesc": {
|
||||||
"description": "Credit description for binimum"
|
"description": "Credit description for binimum"
|
||||||
},
|
},
|
||||||
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
|
"aboutSachinsenalDesc": "The original HiFi project creator. A foundation for lossless-source integration.",
|
||||||
"@aboutSachinsenalDesc": {
|
"@aboutSachinsenalDesc": {
|
||||||
"description": "Credit description for sachinsenal0x64"
|
"description": "Credit description for sachinsenal0x64"
|
||||||
},
|
},
|
||||||
@@ -999,9 +978,9 @@
|
|||||||
"@providerPriorityInfo": {
|
"@providerPriorityInfo": {
|
||||||
"description": "Info tip about fallback behavior"
|
"description": "Info tip about fallback behavior"
|
||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Legacy",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
"description": "Legacy label retained for old generated localization compatibility"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1394,11 +1373,11 @@
|
|||||||
"@storeClearFilters": {
|
"@storeClearFilters": {
|
||||||
"description": "Button to clear all filters"
|
"description": "Button to clear all filters"
|
||||||
},
|
},
|
||||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
"extensionDefaultProvider": "Default Search",
|
||||||
"@extensionDefaultProvider": {
|
"@extensionDefaultProvider": {
|
||||||
"description": "Default search provider option"
|
"description": "Default search provider option"
|
||||||
},
|
},
|
||||||
"extensionDefaultProviderSubtitle": "Use built-in search",
|
"extensionDefaultProviderSubtitle": "Use the default metadata search",
|
||||||
"@extensionDefaultProviderSubtitle": {
|
"@extensionDefaultProviderSubtitle": {
|
||||||
"description": "Subtitle for default provider"
|
"description": "Subtitle for default provider"
|
||||||
},
|
},
|
||||||
@@ -1613,6 +1592,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Choose how album folders are structured",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadSelectQuality": "Select Quality",
|
"downloadSelectQuality": "Select Quality",
|
||||||
"@downloadSelectQuality": {
|
"@downloadSelectQuality": {
|
||||||
"description": "Dialog title - choose audio quality"
|
"description": "Dialog title - choose audio quality"
|
||||||
@@ -2071,43 +2054,43 @@
|
|||||||
},
|
},
|
||||||
"downloadLossy320": "Lossy 320kbps",
|
"downloadLossy320": "Lossy 320kbps",
|
||||||
"@downloadLossy320": {
|
"@downloadLossy320": {
|
||||||
"description": "Quality option label for Tidal lossy 320kbps"
|
"description": "Quality option label for lossy 320kbps"
|
||||||
},
|
},
|
||||||
"downloadLossyFormat": "Lossy Format",
|
"downloadLossyFormat": "Lossy Format",
|
||||||
"@downloadLossyFormat": {
|
"@downloadLossyFormat": {
|
||||||
"description": "Setting title to pick output format for Tidal lossy downloads"
|
"description": "Setting title to pick output format for lossy downloads"
|
||||||
},
|
},
|
||||||
"downloadLossy320Format": "Lossy 320kbps Format",
|
"downloadLossy320Format": "Lossy 320kbps Format",
|
||||||
"@downloadLossy320Format": {
|
"@downloadLossy320Format": {
|
||||||
"description": "Title of the Tidal lossy format picker bottom sheet"
|
"description": "Title of the lossy format picker bottom sheet"
|
||||||
},
|
},
|
||||||
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
|
"downloadLossy320FormatDesc": "Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.",
|
||||||
"@downloadLossy320FormatDesc": {
|
"@downloadLossy320FormatDesc": {
|
||||||
"description": "Description in the Tidal lossy format picker"
|
"description": "Description in the lossy format picker"
|
||||||
},
|
},
|
||||||
"downloadLossyMp3": "MP3 320kbps",
|
"downloadLossyMp3": "MP3 320kbps",
|
||||||
"@downloadLossyMp3": {
|
"@downloadLossyMp3": {
|
||||||
"description": "Tidal lossy format option - MP3 320kbps"
|
"description": "lossy format option - MP3 320kbps"
|
||||||
},
|
},
|
||||||
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
||||||
"@downloadLossyMp3Subtitle": {
|
"@downloadLossyMp3Subtitle": {
|
||||||
"description": "Subtitle for MP3 320kbps Tidal lossy option"
|
"description": "Subtitle for MP3 320kbps lossy option"
|
||||||
},
|
},
|
||||||
"downloadLossyOpus256": "Opus 256kbps",
|
"downloadLossyOpus256": "Opus 256kbps",
|
||||||
"@downloadLossyOpus256": {
|
"@downloadLossyOpus256": {
|
||||||
"description": "Tidal lossy format option - Opus 256kbps"
|
"description": "lossy format option - Opus 256kbps"
|
||||||
},
|
},
|
||||||
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
||||||
"@downloadLossyOpus256Subtitle": {
|
"@downloadLossyOpus256Subtitle": {
|
||||||
"description": "Subtitle for Opus 256kbps Tidal lossy option"
|
"description": "Subtitle for Opus 256kbps lossy option"
|
||||||
},
|
},
|
||||||
"downloadLossyOpus128": "Opus 128kbps",
|
"downloadLossyOpus128": "Opus 128kbps",
|
||||||
"@downloadLossyOpus128": {
|
"@downloadLossyOpus128": {
|
||||||
"description": "Tidal lossy format option - Opus 128kbps"
|
"description": "lossy format option - Opus 128kbps"
|
||||||
},
|
},
|
||||||
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
||||||
"@downloadLossyOpus128Subtitle": {
|
"@downloadLossyOpus128Subtitle": {
|
||||||
"description": "Subtitle for Opus 128kbps Tidal lossy option"
|
"description": "Subtitle for Opus 128kbps lossy option"
|
||||||
},
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
@@ -2697,7 +2680,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from installed download extensions",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -3600,7 +3583,7 @@
|
|||||||
"@lyricsProvidersDescription": {
|
"@lyricsProvidersDescription": {
|
||||||
"description": "Description on the lyrics provider priority page"
|
"description": "Description on the lyrics provider priority page"
|
||||||
},
|
},
|
||||||
"lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.",
|
"lyricsProvidersInfoText": "Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.",
|
||||||
"@lyricsProvidersInfoText": {
|
"@lyricsProvidersInfoText": {
|
||||||
"description": "Info tip on lyrics provider priority page"
|
"description": "Info tip on lyrics provider priority page"
|
||||||
},
|
},
|
||||||
@@ -3838,13 +3821,13 @@
|
|||||||
"@downloadNetworkCompatibilityModeDisabled": {
|
"@downloadNetworkCompatibilityModeDisabled": {
|
||||||
"description": "Subtitle when network compatibility mode is off"
|
"description": "Subtitle when network compatibility mode is off"
|
||||||
},
|
},
|
||||||
"downloadSelectServiceToEnable": "Select Tidal or Qobuz to enable this option",
|
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
|
||||||
"@downloadSelectServiceToEnable": {
|
"@downloadSelectServiceToEnable": {
|
||||||
"description": "Subtitle when quality picker is disabled due to extension service"
|
"description": "Subtitle when quality picker is disabled due to extension service"
|
||||||
},
|
},
|
||||||
"downloadSelectTidalQobuz": "Select Tidal or Qobuz to choose audio quality",
|
"downloadSelectTidalQobuz": "Select a provider with quality options to choose audio quality",
|
||||||
"@downloadSelectTidalQobuz": {
|
"@downloadSelectTidalQobuz": {
|
||||||
"description": "Info shown when a non-built-in service is selected"
|
"description": "Legacy info shown when a provider does not expose quality options"
|
||||||
},
|
},
|
||||||
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
|
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
|
||||||
"@downloadEmbedLyricsDisabled": {
|
"@downloadEmbedLyricsDisabled": {
|
||||||
@@ -4206,7 +4189,7 @@
|
|||||||
},
|
},
|
||||||
"extensionsSearchWith": "Search with {providerName}",
|
"extensionsSearchWith": "Search with {providerName}",
|
||||||
"@extensionsSearchWith": {
|
"@extensionsSearchWith": {
|
||||||
"description": "Extensions page - subtitle for built-in search provider option",
|
"description": "Extensions page - subtitle for default metadata search provider option",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"providerName": {
|
"providerName": {
|
||||||
"type": "String"
|
"type": "String"
|
||||||
|
|||||||
+1052
-96
File diff suppressed because it is too large
Load Diff
+1208
-252
File diff suppressed because it is too large
Load Diff
+1166
-210
File diff suppressed because it is too large
Load Diff
+1058
-102
File diff suppressed because it is too large
Load Diff
+33
-50
@@ -142,9 +142,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
|
"optionsSwitchBack": "Choose the default search provider to switch back from an extension",
|
||||||
"@optionsSwitchBack": {
|
"@optionsSwitchBack": {
|
||||||
"description": "Hint to switch back to built-in providers"
|
"description": "Hint to switch back from extension search"
|
||||||
},
|
},
|
||||||
"optionsAutoFallback": "Auto Fallback",
|
"optionsAutoFallback": "Auto Fallback",
|
||||||
"@optionsAutoFallback": {
|
"@optionsAutoFallback": {
|
||||||
@@ -156,15 +156,15 @@
|
|||||||
},
|
},
|
||||||
"optionsUseExtensionProviders": "Use Extension Providers",
|
"optionsUseExtensionProviders": "Use Extension Providers",
|
||||||
"@optionsUseExtensionProviders": {
|
"@optionsUseExtensionProviders": {
|
||||||
"description": "Enable extension download providers"
|
"description": "Legacy setting label for extension download providers"
|
||||||
},
|
},
|
||||||
"optionsUseExtensionProvidersOn": "Extensions will be tried first",
|
"optionsUseExtensionProvidersOn": "Extension providers are enabled",
|
||||||
"@optionsUseExtensionProvidersOn": {
|
"@optionsUseExtensionProvidersOn": {
|
||||||
"description": "Status when extension providers enabled"
|
"description": "Status when extension providers enabled"
|
||||||
},
|
},
|
||||||
"optionsUseExtensionProvidersOff": "Using built-in providers only",
|
"optionsUseExtensionProvidersOff": "Extension providers are required",
|
||||||
"@optionsUseExtensionProvidersOff": {
|
"@optionsUseExtensionProvidersOff": {
|
||||||
"description": "Status when extension providers disabled"
|
"description": "Legacy status when extension providers would be disabled"
|
||||||
},
|
},
|
||||||
"optionsEmbedLyrics": "Embed Lyrics",
|
"optionsEmbedLyrics": "Embed Lyrics",
|
||||||
"@optionsEmbedLyrics": {
|
"@optionsEmbedLyrics": {
|
||||||
@@ -182,27 +182,6 @@
|
|||||||
"@optionsMaxQualityCoverSubtitle": {
|
"@optionsMaxQualityCoverSubtitle": {
|
||||||
"description": "Subtitle for max quality cover"
|
"description": "Subtitle for max quality cover"
|
||||||
},
|
},
|
||||||
"optionsConcurrentDownloads": "Concurrent Downloads",
|
|
||||||
"@optionsConcurrentDownloads": {
|
|
||||||
"description": "Number of parallel downloads"
|
|
||||||
},
|
|
||||||
"optionsConcurrentSequential": "Sequential (1 at a time)",
|
|
||||||
"@optionsConcurrentSequential": {
|
|
||||||
"description": "Download one at a time"
|
|
||||||
},
|
|
||||||
"optionsConcurrentParallel": "{count} parallel downloads",
|
|
||||||
"@optionsConcurrentParallel": {
|
|
||||||
"description": "Multiple parallel downloads",
|
|
||||||
"placeholders": {
|
|
||||||
"count": {
|
|
||||||
"type": "int"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"optionsConcurrentWarning": "Parallel downloads may trigger rate limiting",
|
|
||||||
"@optionsConcurrentWarning": {
|
|
||||||
"description": "Warning about rate limits"
|
|
||||||
},
|
|
||||||
"optionsExtensionStore": "Extension Store",
|
"optionsExtensionStore": "Extension Store",
|
||||||
"@optionsExtensionStore": {
|
"@optionsExtensionStore": {
|
||||||
"description": "Show/hide store tab"
|
"description": "Show/hide store tab"
|
||||||
@@ -390,11 +369,11 @@
|
|||||||
"@aboutVersion": {
|
"@aboutVersion": {
|
||||||
"description": "Version info label"
|
"description": "Version info label"
|
||||||
},
|
},
|
||||||
"aboutBinimumDesc": "The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn't exist!",
|
"aboutBinimumDesc": "The creator of QQDL & HiFi API. This project helped shape lossless download support.",
|
||||||
"@aboutBinimumDesc": {
|
"@aboutBinimumDesc": {
|
||||||
"description": "Credit description for binimum"
|
"description": "Credit description for binimum"
|
||||||
},
|
},
|
||||||
"aboutSachinsenalDesc": "The original HiFi project creator. The foundation of Tidal integration!",
|
"aboutSachinsenalDesc": "The original HiFi project creator. A foundation for lossless-source integration.",
|
||||||
"@aboutSachinsenalDesc": {
|
"@aboutSachinsenalDesc": {
|
||||||
"description": "Credit description for sachinsenal0x64"
|
"description": "Credit description for sachinsenal0x64"
|
||||||
},
|
},
|
||||||
@@ -999,9 +978,9 @@
|
|||||||
"@providerPriorityInfo": {
|
"@providerPriorityInfo": {
|
||||||
"description": "Info tip about fallback behavior"
|
"description": "Info tip about fallback behavior"
|
||||||
},
|
},
|
||||||
"providerBuiltIn": "Built-in",
|
"providerBuiltIn": "Legacy",
|
||||||
"@providerBuiltIn": {
|
"@providerBuiltIn": {
|
||||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
"description": "Legacy label retained for old generated localization compatibility"
|
||||||
},
|
},
|
||||||
"providerExtension": "Extension",
|
"providerExtension": "Extension",
|
||||||
"@providerExtension": {
|
"@providerExtension": {
|
||||||
@@ -1394,11 +1373,11 @@
|
|||||||
"@storeClearFilters": {
|
"@storeClearFilters": {
|
||||||
"description": "Button to clear all filters"
|
"description": "Button to clear all filters"
|
||||||
},
|
},
|
||||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
"extensionDefaultProvider": "Default Search",
|
||||||
"@extensionDefaultProvider": {
|
"@extensionDefaultProvider": {
|
||||||
"description": "Default search provider option"
|
"description": "Default search provider option"
|
||||||
},
|
},
|
||||||
"extensionDefaultProviderSubtitle": "Use built-in search",
|
"extensionDefaultProviderSubtitle": "Use the default metadata search",
|
||||||
"@extensionDefaultProviderSubtitle": {
|
"@extensionDefaultProviderSubtitle": {
|
||||||
"description": "Subtitle for default provider"
|
"description": "Subtitle for default provider"
|
||||||
},
|
},
|
||||||
@@ -1613,6 +1592,10 @@
|
|||||||
"@downloadAlbumFolderStructure": {
|
"@downloadAlbumFolderStructure": {
|
||||||
"description": "Setting - album folder organization"
|
"description": "Setting - album folder organization"
|
||||||
},
|
},
|
||||||
|
"albumFolderStructureDescription": "Choose how album folders are structured",
|
||||||
|
"@albumFolderStructureDescription": {
|
||||||
|
"description": "Album folder structure picker description"
|
||||||
|
},
|
||||||
"downloadSelectQuality": "Select Quality",
|
"downloadSelectQuality": "Select Quality",
|
||||||
"@downloadSelectQuality": {
|
"@downloadSelectQuality": {
|
||||||
"description": "Dialog title - choose audio quality"
|
"description": "Dialog title - choose audio quality"
|
||||||
@@ -2071,43 +2054,43 @@
|
|||||||
},
|
},
|
||||||
"downloadLossy320": "Lossy 320kbps",
|
"downloadLossy320": "Lossy 320kbps",
|
||||||
"@downloadLossy320": {
|
"@downloadLossy320": {
|
||||||
"description": "Quality option label for Tidal lossy 320kbps"
|
"description": "Quality option label for lossy 320kbps"
|
||||||
},
|
},
|
||||||
"downloadLossyFormat": "Lossy Format",
|
"downloadLossyFormat": "Lossy Format",
|
||||||
"@downloadLossyFormat": {
|
"@downloadLossyFormat": {
|
||||||
"description": "Setting title to pick output format for Tidal lossy downloads"
|
"description": "Setting title to pick output format for lossy downloads"
|
||||||
},
|
},
|
||||||
"downloadLossy320Format": "Lossy 320kbps Format",
|
"downloadLossy320Format": "Lossy 320kbps Format",
|
||||||
"@downloadLossy320Format": {
|
"@downloadLossy320Format": {
|
||||||
"description": "Title of the Tidal lossy format picker bottom sheet"
|
"description": "Title of the lossy format picker bottom sheet"
|
||||||
},
|
},
|
||||||
"downloadLossy320FormatDesc": "Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.",
|
"downloadLossy320FormatDesc": "Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.",
|
||||||
"@downloadLossy320FormatDesc": {
|
"@downloadLossy320FormatDesc": {
|
||||||
"description": "Description in the Tidal lossy format picker"
|
"description": "Description in the lossy format picker"
|
||||||
},
|
},
|
||||||
"downloadLossyMp3": "MP3 320kbps",
|
"downloadLossyMp3": "MP3 320kbps",
|
||||||
"@downloadLossyMp3": {
|
"@downloadLossyMp3": {
|
||||||
"description": "Tidal lossy format option - MP3 320kbps"
|
"description": "lossy format option - MP3 320kbps"
|
||||||
},
|
},
|
||||||
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
||||||
"@downloadLossyMp3Subtitle": {
|
"@downloadLossyMp3Subtitle": {
|
||||||
"description": "Subtitle for MP3 320kbps Tidal lossy option"
|
"description": "Subtitle for MP3 320kbps lossy option"
|
||||||
},
|
},
|
||||||
"downloadLossyOpus256": "Opus 256kbps",
|
"downloadLossyOpus256": "Opus 256kbps",
|
||||||
"@downloadLossyOpus256": {
|
"@downloadLossyOpus256": {
|
||||||
"description": "Tidal lossy format option - Opus 256kbps"
|
"description": "lossy format option - Opus 256kbps"
|
||||||
},
|
},
|
||||||
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
||||||
"@downloadLossyOpus256Subtitle": {
|
"@downloadLossyOpus256Subtitle": {
|
||||||
"description": "Subtitle for Opus 256kbps Tidal lossy option"
|
"description": "Subtitle for Opus 256kbps lossy option"
|
||||||
},
|
},
|
||||||
"downloadLossyOpus128": "Opus 128kbps",
|
"downloadLossyOpus128": "Opus 128kbps",
|
||||||
"@downloadLossyOpus128": {
|
"@downloadLossyOpus128": {
|
||||||
"description": "Tidal lossy format option - Opus 128kbps"
|
"description": "lossy format option - Opus 128kbps"
|
||||||
},
|
},
|
||||||
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
||||||
"@downloadLossyOpus128Subtitle": {
|
"@downloadLossyOpus128Subtitle": {
|
||||||
"description": "Subtitle for Opus 128kbps Tidal lossy option"
|
"description": "Subtitle for Opus 128kbps lossy option"
|
||||||
},
|
},
|
||||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||||
"@downloadUseAlbumArtistForFolders": {
|
"@downloadUseAlbumArtistForFolders": {
|
||||||
@@ -2697,7 +2680,7 @@
|
|||||||
"@tutorialWelcomeTip1": {
|
"@tutorialWelcomeTip1": {
|
||||||
"description": "Tutorial welcome tip 1"
|
"description": "Tutorial welcome tip 1"
|
||||||
},
|
},
|
||||||
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer",
|
"tutorialWelcomeTip2": "Get FLAC quality audio from installed download extensions",
|
||||||
"@tutorialWelcomeTip2": {
|
"@tutorialWelcomeTip2": {
|
||||||
"description": "Tutorial welcome tip 2"
|
"description": "Tutorial welcome tip 2"
|
||||||
},
|
},
|
||||||
@@ -3600,7 +3583,7 @@
|
|||||||
"@lyricsProvidersDescription": {
|
"@lyricsProvidersDescription": {
|
||||||
"description": "Description on the lyrics provider priority page"
|
"description": "Description on the lyrics provider priority page"
|
||||||
},
|
},
|
||||||
"lyricsProvidersInfoText": "Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.",
|
"lyricsProvidersInfoText": "Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.",
|
||||||
"@lyricsProvidersInfoText": {
|
"@lyricsProvidersInfoText": {
|
||||||
"description": "Info tip on lyrics provider priority page"
|
"description": "Info tip on lyrics provider priority page"
|
||||||
},
|
},
|
||||||
@@ -3838,13 +3821,13 @@
|
|||||||
"@downloadNetworkCompatibilityModeDisabled": {
|
"@downloadNetworkCompatibilityModeDisabled": {
|
||||||
"description": "Subtitle when network compatibility mode is off"
|
"description": "Subtitle when network compatibility mode is off"
|
||||||
},
|
},
|
||||||
"downloadSelectServiceToEnable": "Select Tidal or Qobuz to enable this option",
|
"downloadSelectServiceToEnable": "Select a provider with quality options to enable this option",
|
||||||
"@downloadSelectServiceToEnable": {
|
"@downloadSelectServiceToEnable": {
|
||||||
"description": "Subtitle when quality picker is disabled due to extension service"
|
"description": "Subtitle when quality picker is disabled due to extension service"
|
||||||
},
|
},
|
||||||
"downloadSelectTidalQobuz": "Select Tidal or Qobuz to choose audio quality",
|
"downloadSelectTidalQobuz": "Select a provider with quality options to choose audio quality",
|
||||||
"@downloadSelectTidalQobuz": {
|
"@downloadSelectTidalQobuz": {
|
||||||
"description": "Info shown when a non-built-in service is selected"
|
"description": "Legacy info shown when a provider does not expose quality options"
|
||||||
},
|
},
|
||||||
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
|
"downloadEmbedLyricsDisabled": "Enable metadata embedding first",
|
||||||
"@downloadEmbedLyricsDisabled": {
|
"@downloadEmbedLyricsDisabled": {
|
||||||
@@ -4206,7 +4189,7 @@
|
|||||||
},
|
},
|
||||||
"extensionsSearchWith": "Search with {providerName}",
|
"extensionsSearchWith": "Search with {providerName}",
|
||||||
"@extensionsSearchWith": {
|
"@extensionsSearchWith": {
|
||||||
"description": "Extensions page - subtitle for built-in search provider option",
|
"description": "Extensions page - subtitle for default metadata search provider option",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"providerName": {
|
"providerName": {
|
||||||
"type": "String"
|
"type": "String"
|
||||||
|
|||||||
+1061
-105
File diff suppressed because it is too large
Load Diff
+1052
-96
File diff suppressed because it is too large
Load Diff
@@ -14,23 +14,27 @@ const int translationThreshold = 70;
|
|||||||
/// Only these languages will be available in the app.
|
/// Only these languages will be available in the app.
|
||||||
const List<Locale> filteredSupportedLocales = <Locale>[
|
const List<Locale> filteredSupportedLocales = <Locale>[
|
||||||
Locale('en'),
|
Locale('en'),
|
||||||
Locale('ru'),
|
Locale('fr'),
|
||||||
|
Locale('de'),
|
||||||
Locale('es', 'ES'),
|
Locale('es', 'ES'),
|
||||||
Locale('id'),
|
|
||||||
Locale('pt', 'PT'),
|
|
||||||
Locale('ja'),
|
|
||||||
Locale('tr'),
|
|
||||||
Locale('uk'),
|
Locale('uk'),
|
||||||
|
Locale('ru'),
|
||||||
|
Locale('tr'),
|
||||||
|
Locale('id'),
|
||||||
|
Locale('ja'),
|
||||||
|
Locale('pt', 'PT'),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Set of locale codes for quick lookup.
|
/// Set of locale codes for quick lookup.
|
||||||
const Set<String> filteredLocaleCodes = <String>{
|
const Set<String> filteredLocaleCodes = <String>{
|
||||||
'en',
|
'en',
|
||||||
'ru',
|
'fr',
|
||||||
|
'de',
|
||||||
'es_ES',
|
'es_ES',
|
||||||
'id',
|
|
||||||
'pt_PT',
|
|
||||||
'ja',
|
|
||||||
'tr',
|
|
||||||
'uk',
|
'uk',
|
||||||
|
'ru',
|
||||||
|
'tr',
|
||||||
|
'id',
|
||||||
|
'ja',
|
||||||
|
'pt_PT',
|
||||||
};
|
};
|
||||||
|
|||||||
+34
-11
@@ -15,20 +15,43 @@ import 'package:spotiflac_android/services/notification_service.dart';
|
|||||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||||
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
|
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
void main() async {
|
final _log = AppLogger('Main');
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
|
||||||
final runtimeProfile = await _resolveRuntimeProfile();
|
|
||||||
_configureImageCache(runtimeProfile);
|
|
||||||
|
|
||||||
runApp(
|
void main() {
|
||||||
ProviderScope(
|
// Catch uncaught Dart errors so a failing async path is logged, not fatal.
|
||||||
child: _EagerInitialization(
|
// Native (Go) crashes still can't be caught here.
|
||||||
child: SpotiFLACApp(
|
runZonedGuarded(
|
||||||
disableOverscrollEffects: runtimeProfile.disableOverscrollEffects,
|
() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
final previousOnError = FlutterError.onError;
|
||||||
|
FlutterError.onError = (details) {
|
||||||
|
previousOnError?.call(details);
|
||||||
|
_log.e('Uncaught Flutter error: ${details.exceptionAsString()}');
|
||||||
|
};
|
||||||
|
WidgetsBinding.instance.platformDispatcher.onError = (error, stack) {
|
||||||
|
_log.e('Uncaught platform error: $error');
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
final runtimeProfile = await _resolveRuntimeProfile();
|
||||||
|
_configureImageCache(runtimeProfile);
|
||||||
|
|
||||||
|
runApp(
|
||||||
|
ProviderScope(
|
||||||
|
child: _EagerInitialization(
|
||||||
|
child: SpotiFLACApp(
|
||||||
|
disableOverscrollEffects: runtimeProfile.disableOverscrollEffects,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
|
(error, stack) {
|
||||||
|
_log.e('Uncaught zone error: $error');
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user