mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-02 19:05:57 +02:00
Compare commits
286 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 | |||
| cfda124995 | |||
| 212f1cacca | |||
| dd89de7cad | |||
| 8b4372dc7f | |||
| 2a25557632 | |||
| 0cbb339948 | |||
| 1496f51e30 | |||
| d1c5fe0605 | |||
| 56786f60ff | |||
| af5d36f69f | |||
| e40da71ef8 | |||
| 26b8bf422c | |||
| 0a545706bd | |||
| 9ebac610c7 | |||
| 5fc8a6af2a | |||
| 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 | |||
| 8011d41e53 | |||
| 5412f23d26 | |||
| 0c39ff47f2 | |||
| 537af905f6 | |||
| 6b4f70bde3 | |||
| be2b6d2c1f | |||
| 0c1a6d8f19 | |||
| 2821997260 | |||
| 0546a33b10 | |||
| deb98d8dfb | |||
| 72c658eda7 | |||
| df17f10c8a | |||
| 9cacf2dc8e | |||
| c7bc9f5b1c | |||
| 49ba8ae0d2 | |||
| 3a62442ed0 | |||
| 3a1b92f9c4 | |||
| 30f97394ec | |||
| 592308c1c6 | |||
| 8bcfc63da0 | |||
| a9cfff2692 | |||
| 9e7ff56113 | |||
| 9071143bbd | |||
| 40770aff15 | |||
| 2bc5ef34ee | |||
| 6b9a3d95cd | |||
| 4fe51cef96 | |||
| 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
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
java-version: "25"
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
@@ -257,6 +257,15 @@ jobs:
|
||||
- name: Get Flutter dependencies
|
||||
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
|
||||
run: dart run flutter_launcher_icons
|
||||
|
||||
@@ -379,8 +388,6 @@ jobs:
|
||||
### Installation
|
||||
**Android**: Enable "Install from unknown sources" and install the APK
|
||||
**iOS**: Use AltStore, Sideloadly, or similar tools to sideload the IPA
|
||||
|
||||
  
|
||||
FOOTER
|
||||
|
||||
echo "Release body:"
|
||||
@@ -390,7 +397,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
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
|
||||
files: ./release/*
|
||||
draft: false
|
||||
@@ -556,7 +563,7 @@ jobs:
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${ARM64_APK}" \
|
||||
-F caption="SpotiFLAC ${VERSION} - arm64 (recommended)"
|
||||
-F caption="SpotiFLAC Mobile ${VERSION} - arm64 (recommended)"
|
||||
fi
|
||||
|
||||
# Upload arm32 APK to channel
|
||||
@@ -565,7 +572,7 @@ jobs:
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${ARM32_APK}" \
|
||||
-F caption="SpotiFLAC ${VERSION} - arm32"
|
||||
-F caption="SpotiFLAC Mobile ${VERSION} - arm32"
|
||||
fi
|
||||
|
||||
# Upload iOS IPA to channel
|
||||
@@ -575,7 +582,7 @@ jobs:
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendDocument" \
|
||||
-F chat_id="${TELEGRAM_CHANNEL_ID}" \
|
||||
-F document=@"${IOS_IPA}" \
|
||||
-F caption="SpotiFLAC ${VERSION} - iOS (unsigned, sideload required)"
|
||||
-F caption="SpotiFLAC Mobile ${VERSION} - iOS (unsigned, sideload required)"
|
||||
fi
|
||||
|
||||
echo "Telegram notification sent!"
|
||||
|
||||
+4
-1
@@ -60,12 +60,15 @@ ios/Flutter/Flutter.framework/
|
||||
ios/Flutter/Flutter.podspec
|
||||
|
||||
# Extension folder
|
||||
extension/
|
||||
extension/*
|
||||
extension/v2/
|
||||
extension/v2/**
|
||||
|
||||
# Agent instructions
|
||||
AGENTS.md
|
||||
|
||||
# Temp/misc
|
||||
.tmp/
|
||||
nul
|
||||
NUL
|
||||
network_requests.txt
|
||||
|
||||
@@ -59,7 +59,7 @@ Extensions let the community add new music sources and features without waiting
|
||||
## Related Projects
|
||||
|
||||
### [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)
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
<br>
|
||||
|
||||
Quality depends on what's available from the streaming service and its extensions. Built-in providers:
|
||||
- **Tidal** up to 24-bit/192kHz
|
||||
- **Qobuz** up to 24-bit/192kHz
|
||||
- **Deezer** up to 16-bit/44.1kHz
|
||||
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.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ if (keystorePropertiesFile.exists()) {
|
||||
|
||||
android {
|
||||
namespace = "com.zarz.spotiflac"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
compileSdk = 37
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
buildFeatures {
|
||||
@@ -26,13 +26,13 @@ android {
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
sourceCompatibility = JavaVersion.VERSION_25
|
||||
targetCompatibility = JavaVersion.VERSION_25
|
||||
}
|
||||
|
||||
kotlin {
|
||||
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 {
|
||||
applicationId = "com.zarz.spotiflac"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = 36
|
||||
targetSdk = 37
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
multiDexEnabled = true
|
||||
@@ -62,6 +62,8 @@ android {
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
|
||||
@@ -100,6 +100,12 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="spotify-callback" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="session-grant" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Download Service -->
|
||||
@@ -108,6 +114,23 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name="com.ryanheise.audioservice.AudioService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true"
|
||||
android:enabled="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<receiver
|
||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- flutter_local_notifications receivers -->
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||
@@ -124,6 +147,10 @@
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
|
||||
<!-- FileProvider for APK installation -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
@@ -17,6 +18,7 @@ import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.FlutterShellArgs
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import com.ryanheise.audioservice.AudioServicePlugin
|
||||
import gobackend.Gobackend
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -36,6 +38,10 @@ import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
|
||||
class MainActivity: FlutterFragmentActivity() {
|
||||
override fun provideFlutterEngine(context: Context): FlutterEngine {
|
||||
return AudioServicePlugin.getFlutterEngine(context)
|
||||
}
|
||||
|
||||
private val CHANNEL = "com.zarz.spotiflac/backend"
|
||||
private val DOWNLOAD_PROGRESS_STREAM_CHANNEL =
|
||||
"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_THRESHOLD_BYTES = 256 * 1024
|
||||
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 val safScanLock = Any()
|
||||
private val safDirLock = Any()
|
||||
@@ -148,8 +156,15 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"mali-t7",
|
||||
"powervr sgx",
|
||||
"powervr ge8320",
|
||||
"vivante",
|
||||
"gc1000",
|
||||
"gc2000",
|
||||
"gc4000",
|
||||
"gc5000",
|
||||
"gc7000",
|
||||
"gc8000",
|
||||
"gc820",
|
||||
"gc880",
|
||||
)
|
||||
|
||||
private val PROBLEMATIC_CHIPSETS = listOf(
|
||||
@@ -163,6 +178,15 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"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(
|
||||
"sm-t220",
|
||||
"sm-t225",
|
||||
@@ -173,6 +197,14 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val board = Build.BOARD.lowercase(Locale.ROOT)
|
||||
val model = Build.MODEL.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) {
|
||||
if (model.contains(problematicModel) || device.contains(problematicModel)) {
|
||||
@@ -307,6 +339,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
".mp3" -> "audio/mpeg"
|
||||
".opus" -> "audio/ogg"
|
||||
".flac" -> "audio/flac"
|
||||
".wav" -> "audio/wav"
|
||||
".aiff", ".aif", ".aifc" -> "audio/aiff"
|
||||
".lrc" -> "application/octet-stream"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
@@ -791,6 +825,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"audio/mpeg" -> ".mp3"
|
||||
"audio/ogg" -> ".opus"
|
||||
"audio/flac" -> ".flac"
|
||||
"audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave" -> ".wav"
|
||||
"audio/aiff", "audio/x-aiff" -> ".aiff"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
@@ -1113,6 +1149,16 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
".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(
|
||||
dir: DocumentFile,
|
||||
cache: MutableMap<String, Map<String, DocumentFile>>,
|
||||
@@ -1182,7 +1228,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
it.currentFile = "Scanning folders..."
|
||||
}
|
||||
|
||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg")
|
||||
val supportedAudioExt = libraryScanAudioExtensions
|
||||
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
|
||||
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||
val visitedDirUris = mutableSetOf<String>()
|
||||
@@ -1482,7 +1528,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
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 cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
|
||||
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||
@@ -2035,14 +2081,22 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
val host = (uri.host ?: "").lowercase(Locale.US)
|
||||
val path = (uri.path ?: "").lowercase(Locale.US)
|
||||
val isSessionGrant = host == "session-grant"
|
||||
val isCallback =
|
||||
host == "callback" ||
|
||||
isSessionGrant ||
|
||||
host == "callback" ||
|
||||
host == "spotify-callback" ||
|
||||
path.contains("callback")
|
||||
if (!isCallback) {
|
||||
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()) {
|
||||
return
|
||||
}
|
||||
@@ -2054,15 +2108,43 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
intent.data = null
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Gobackend.setExtensionAuthCodeByID(extId, code)
|
||||
val json = Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin")
|
||||
android.util.Log.i("SpotiFLAC", "Extension OAuth complete for $extId: $json")
|
||||
val json = if (isSessionGrant) {
|
||||
Gobackend.setExtensionSessionGrantByID(extId, code)
|
||||
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) {
|
||||
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() {
|
||||
try {
|
||||
Gobackend.cleanupExtensions()
|
||||
@@ -2126,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 {
|
||||
try {
|
||||
when (call.method) {
|
||||
@@ -2211,6 +2303,13 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"setAllowPrivateNetwork" -> {
|
||||
val allowed = call.argument<Boolean>("allowed") ?: false
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.setAllowPrivateNetwork(allowed)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"checkDuplicate" -> {
|
||||
val outputDir = call.argument<String>("output_dir") ?: ""
|
||||
val isrc = call.argument<String>("isrc") ?: ""
|
||||
@@ -2629,6 +2728,46 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
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" -> {
|
||||
val tempPath = call.argument<String>("temp_path") ?: ""
|
||||
val safUri = call.argument<String>("saf_uri") ?: ""
|
||||
|
||||
@@ -334,7 +334,6 @@ object NativeDownloadFinalizer {
|
||||
}
|
||||
|
||||
private fun currentStatus(@Suppress("UNUSED_PARAMETER") status: String) {
|
||||
// Kept as a narrow hook for future richer progress snapshots.
|
||||
}
|
||||
|
||||
private fun cleanupFailedFinalizationOutput(
|
||||
@@ -422,16 +421,19 @@ object NativeDownloadFinalizer {
|
||||
try {
|
||||
for (candidate in decryptionKeyCandidates(key)) {
|
||||
checkCancelled(shouldCancel)
|
||||
val attempts = mutableListOf<Pair<String, Boolean>>()
|
||||
attempts.add(outputPath to (preferredExt == ".flac"))
|
||||
val attempts = mutableListOf<Triple<String, Boolean, Boolean>>()
|
||||
attempts.add(Triple(outputPath, preferredExt == ".flac", false))
|
||||
if (preferredExt == ".flac") {
|
||||
attempts.add(buildOutputPath(localInput, ".m4a") to false)
|
||||
attempts.add(Triple(buildOutputPath(localInput, ".m4a"), false, false))
|
||||
}
|
||||
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 {
|
||||
val audioMap = if (mapAudioOnly) "-map 0:a " else ""
|
||||
// Force the flac muxer when the target extension is
|
||||
@@ -439,7 +441,11 @@ object NativeDownloadFinalizer {
|
||||
// stream layout, producing FLAC-in-MP4 under a .flac
|
||||
// filename which downstream native FLAC tag writers
|
||||
// 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 result = runFFmpeg(command, shouldCancel)
|
||||
lastOutput = result.second
|
||||
@@ -1159,18 +1165,28 @@ object NativeDownloadFinalizer {
|
||||
val mp3Flags = if (format == "mp3") "-id3v2_version 3 " else ""
|
||||
var adoptedTemp = 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)} " +
|
||||
"-map 0:a -c:a copy -map_metadata 0 -map 1:v -c:v copy " +
|
||||
"-disposition:v:0 attached_pic " +
|
||||
"-metadata:s:v ${q("title=Album cover")} " +
|
||||
"-metadata:s:v ${q("comment=Cover (front)")} " +
|
||||
"$metadataArgs -f mp4 ${q(temp.absolutePath)} -y"
|
||||
"$metadataArgs -f ${if (forceMov) "mov" else "mp4"} ${q(temp.absolutePath)} -y"
|
||||
} 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 (inputFile.delete()) {
|
||||
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") {
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
sourceCompatibility = JavaVersion.VERSION_25
|
||||
targetCompatibility = JavaVersion.VERSION_25
|
||||
}
|
||||
|
||||
// Enable multidex for all subprojects
|
||||
@@ -27,7 +27,7 @@ subprojects {
|
||||
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"name": "SpotiFLAC Mobile",
|
||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||
"developerName": "zarzet",
|
||||
"version": "4.5.5",
|
||||
"versionDate": "2026-05-14",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.5/SpotiFLAC-v4.5.5-ios-unsigned.ipa",
|
||||
"version": "4.7.1",
|
||||
"versionDate": "2026-07-01",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.7.1/SpotiFLAC-v4.7.1-ios-unsigned.ipa",
|
||||
"localizedDescription": "SpotiFLAC Mobile is written in Flutter. Download tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||
"size": 34915749
|
||||
"size": 37455821
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+4
-5
@@ -3,9 +3,11 @@ files:
|
||||
translation: /lib/l10n/arb/app_%locale%.arb
|
||||
languages_mapping:
|
||||
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
|
||||
es: es
|
||||
es-ES: es_ES
|
||||
fr: fr
|
||||
hi: hi
|
||||
@@ -13,12 +15,9 @@ files:
|
||||
ja: ja
|
||||
ko: ko
|
||||
nl: nl
|
||||
pt: pt
|
||||
pt-PT: pt_PT
|
||||
ru: ru
|
||||
tr: tr
|
||||
uk: uk
|
||||
zh: zh
|
||||
# Full codes for Chinese variants
|
||||
zh-CN: zh_CN
|
||||
zh-TW: zh_TW
|
||||
|
||||
@@ -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)
|
||||
footer := buildAPEHeaderFooter(version, tagSize, itemCount, footerFlags)
|
||||
|
||||
// Final layout: header + items + footer
|
||||
result := make([]byte, 0, len(header)+len(itemsData)+len(footer))
|
||||
result = append(result, header...)
|
||||
result = append(result, itemsData...)
|
||||
|
||||
@@ -1624,6 +1624,9 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
|
||||
}
|
||||
return data, mimeType, nil
|
||||
|
||||
case ".wav", ".aiff", ".aif", ".aifc":
|
||||
return extractWAVAIFFCover(filePath)
|
||||
|
||||
default:
|
||||
return nil, "", fmt.Errorf("unsupported format: %s", ext)
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
||||
}
|
||||
|
||||
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 {
|
||||
candidate = filepath.Join(cueDir, baseName+ext)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
|
||||
+23
-12
@@ -3,6 +3,7 @@ package gobackend
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"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)
|
||||
// are skipped.
|
||||
func (c *DeezerClient) fetchAlbumTrackCounts(ctx context.Context, albums []ArtistAlbumMetadata) {
|
||||
// Find albums that need track counts
|
||||
type indexedID struct {
|
||||
idx int
|
||||
albumID string
|
||||
@@ -1267,16 +1267,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
errStr := err.Error()
|
||||
|
||||
isRetryable := strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "connection reset") ||
|
||||
strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "EOF") ||
|
||||
strings.Contains(errStr, "status 5") ||
|
||||
strings.Contains(errStr, "status 429")
|
||||
|
||||
if !isRetryable {
|
||||
if !isDeezerRetryableError(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)
|
||||
}
|
||||
|
||||
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 {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
@@ -1306,7 +1317,7 @@ func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst inter
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
+249
-17
@@ -283,6 +283,7 @@ type DownloadRequest struct {
|
||||
PostProcessingEnabled bool `json:"post_processing_enabled,omitempty"`
|
||||
TidalHighFormat string `json:"tidal_high_format,omitempty"`
|
||||
TrackNumber int `json:"track_number"`
|
||||
PlaylistPosition int `json:"playlist_position,omitempty"`
|
||||
DiscNumber int `json:"disc_number"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
@@ -310,6 +311,7 @@ type DownloadResponse struct {
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"`
|
||||
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||
@@ -1160,6 +1162,8 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
isApe := strings.HasSuffix(lower, ".ape")
|
||||
isWv := strings.HasSuffix(lower, ".wv")
|
||||
isMpc := strings.HasSuffix(lower, ".mpc")
|
||||
isWav := strings.HasSuffix(lower, ".wav")
|
||||
isAiff := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
|
||||
|
||||
result := map[string]interface{}{
|
||||
"title": "",
|
||||
@@ -1376,7 +1380,6 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
} else if isApe || isWv || isMpc {
|
||||
result["format"] = strings.TrimPrefix(filepath.Ext(filePath), ".")
|
||||
result["audio_codec"] = result["format"]
|
||||
// APE, WavPack, Musepack: read APEv2 tags
|
||||
apeTag, apeErr := ReadAPETags(filePath)
|
||||
if apeErr == nil && apeTag != nil {
|
||||
meta := APETagToAudioMetadata(apeTag)
|
||||
@@ -1406,6 +1409,51 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
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 {
|
||||
return "", fmt.Errorf("unsupported file format: %s", filePath)
|
||||
}
|
||||
@@ -1463,6 +1511,48 @@ func ScanCueSheetForLibraryWithCoverCacheKey(cuePath, audioDir, virtualPathPrefi
|
||||
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.
|
||||
func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
var fields map[string]string
|
||||
@@ -1474,6 +1564,8 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
isFlac := strings.HasSuffix(lower, ".flac")
|
||||
isApeFile := strings.HasSuffix(lower, ".ape") || strings.HasSuffix(lower, ".wv") || strings.HasSuffix(lower, ".mpc")
|
||||
isM4AFile := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".m4b")
|
||||
isWavFile := strings.HasSuffix(lower, ".wav")
|
||||
isAiffFile := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
|
||||
coverPath := strings.TrimSpace(fields["cover_path"])
|
||||
|
||||
if hasOnlyM4AReplayGainFields(fields) && (isM4AFile || isMP4ContainerFile(filePath)) {
|
||||
@@ -1502,7 +1594,24 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
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 {
|
||||
trackNum := 0
|
||||
totalTracks := 0
|
||||
@@ -1751,9 +1860,13 @@ func GetLyricsLRCWithSource(spotifyID, trackName, artistName string, filePath st
|
||||
if filePath != "" {
|
||||
lyrics, err := ExtractLyrics(filePath)
|
||||
if err == nil && lyrics != "" {
|
||||
source := extractLyricsSourceFromLRC(lyrics)
|
||||
if source == "" {
|
||||
source = "Embedded"
|
||||
}
|
||||
result := map[string]interface{}{
|
||||
"lyrics": lyrics,
|
||||
"source": "Embedded",
|
||||
"source": source,
|
||||
"sync_type": "EMBEDDED",
|
||||
"instrumental": false,
|
||||
}
|
||||
@@ -1964,6 +2077,7 @@ func normalizeExtensionTrackMetadataMap(
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": coverURL,
|
||||
"cover_url": coverURL,
|
||||
"preview_url": track.PreviewURL,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": trackNum,
|
||||
"total_tracks": track.TotalTracks,
|
||||
@@ -1992,9 +2106,12 @@ func normalizeExtensionAlbumInfoMap(album *ExtAlbumMetadata) map[string]interfac
|
||||
"artist_id": album.ArtistID,
|
||||
"images": album.CoverURL,
|
||||
"cover_url": album.CoverURL,
|
||||
"header_image": album.HeaderImage,
|
||||
"header_video": album.HeaderVideo,
|
||||
"release_date": album.ReleaseDate,
|
||||
"total_tracks": album.TotalTracks,
|
||||
"album_type": album.AlbumType,
|
||||
"audio_traits": album.AudioTraits,
|
||||
"provider_id": album.ProviderID,
|
||||
}
|
||||
}
|
||||
@@ -2079,11 +2196,13 @@ func getExtensionProviderMetadataResponse(
|
||||
|
||||
return map[string]interface{}{
|
||||
"playlist_info": map[string]interface{}{
|
||||
"id": playlist.ID,
|
||||
"name": playlist.Name,
|
||||
"images": playlist.CoverURL,
|
||||
"cover_url": playlist.CoverURL,
|
||||
"provider_id": playlist.ProviderID,
|
||||
"id": playlist.ID,
|
||||
"name": playlist.Name,
|
||||
"images": playlist.CoverURL,
|
||||
"cover_url": playlist.CoverURL,
|
||||
"header_image": playlist.HeaderImage,
|
||||
"header_video": playlist.HeaderVideo,
|
||||
"provider_id": playlist.ProviderID,
|
||||
"owner": map[string]interface{}{
|
||||
"name": playlist.Artists,
|
||||
"images": playlist.CoverURL,
|
||||
@@ -2112,6 +2231,7 @@ func getExtensionProviderMetadataResponse(
|
||||
"images": firstNonEmptyTrimmed(artist.HeaderImage, artist.ImageURL),
|
||||
"cover_url": artist.ImageURL,
|
||||
"header_image": artist.HeaderImage,
|
||||
"header_video": artist.HeaderVideo,
|
||||
"provider_id": artist.ProviderID,
|
||||
},
|
||||
"albums": albums,
|
||||
@@ -2161,6 +2281,16 @@ func GetProviderMetadataJSON(providerID, resourceType, resourceID string) (strin
|
||||
|
||||
switch strings.ToLower(trimmedProviderID) {
|
||||
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)
|
||||
default:
|
||||
response, err := getExtensionProviderMetadataResponse(trimmedProviderID, resourceType, resourceID)
|
||||
@@ -2176,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) {
|
||||
if trackID == "" {
|
||||
return "", fmt.Errorf("empty track ID")
|
||||
@@ -2399,8 +2542,19 @@ func classifyDownloadErrorType(msg string) string {
|
||||
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") ||
|
||||
strings.Contains(lowerMsg, "429") ||
|
||||
messageHasHTTPStatusCode(lowerMsg, "429") ||
|
||||
strings.Contains(lowerMsg, "too many requests") {
|
||||
return "rate_limit"
|
||||
} else if strings.Contains(lowerMsg, "permission") ||
|
||||
@@ -2425,6 +2579,15 @@ func classifyDownloadErrorType(msg string) string {
|
||||
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 {
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("no cover URL provided")
|
||||
@@ -2577,8 +2740,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
|
||||
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 {
|
||||
found := false
|
||||
|
||||
@@ -2747,7 +2908,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
}
|
||||
|
||||
if isFlac {
|
||||
// Native Go FLAC metadata embedding.
|
||||
// Only populate Metadata fields for selected update groups; empty/zero
|
||||
// values cause EmbedMetadata's setComment() to skip those tags,
|
||||
// preserving whatever is already in the file.
|
||||
@@ -3127,7 +3287,7 @@ func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
|
||||
}
|
||||
|
||||
func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
||||
req := GetPendingAuthRequest(extensionID)
|
||||
req := ensureExtensionPendingAuthRequest(extensionID)
|
||||
if req == nil {
|
||||
return "", nil
|
||||
}
|
||||
@@ -3146,10 +3306,48 @@ func GetExtensionPendingAuthJSON(extensionID string) (string, error) {
|
||||
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) {
|
||||
SetExtensionAuthCode(extensionID, authCode)
|
||||
}
|
||||
|
||||
func SetExtensionSessionGrantByID(extensionID, grant string) {
|
||||
setPendingSignedSessionGrant(extensionID, grant)
|
||||
}
|
||||
|
||||
func SetExtensionTokensByID(extensionID, accessToken, refreshToken string, expiresIn int) {
|
||||
var expiresAt time.Time
|
||||
if expiresIn > 0 {
|
||||
@@ -3316,6 +3514,7 @@ func CustomSearchWithExtensionJSONWithRequestID(extensionID, query string, optio
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": track.ResolvedCoverURL(),
|
||||
"preview_url": track.PreviewURL,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
@@ -3381,6 +3580,8 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
"extension_id": extensionID,
|
||||
"name": result.Name,
|
||||
"cover_url": result.CoverURL,
|
||||
"header_image": result.HeaderImage,
|
||||
"header_video": result.HeaderVideo,
|
||||
}
|
||||
|
||||
if result.Track != nil {
|
||||
@@ -3392,6 +3593,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
"album_artist": result.Track.AlbumArtist,
|
||||
"duration_ms": result.Track.DurationMS,
|
||||
"images": result.Track.ResolvedCoverURL(),
|
||||
"preview_url": result.Track.PreviewURL,
|
||||
"release_date": result.Track.ReleaseDate,
|
||||
"track_number": result.Track.TrackNumber,
|
||||
"total_tracks": result.Track.TotalTracks,
|
||||
@@ -3414,6 +3616,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": track.ResolvedCoverURL(),
|
||||
"preview_url": track.PreviewURL,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
@@ -3435,6 +3638,9 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
"name": result.Album.Name,
|
||||
"artists": result.Album.Artists,
|
||||
"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,
|
||||
"total_tracks": result.Album.TotalTracks,
|
||||
"album_type": result.Album.AlbumType,
|
||||
@@ -3448,6 +3654,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
"name": result.Artist.Name,
|
||||
"image_url": result.Artist.ImageURL,
|
||||
"header_image": result.Artist.HeaderImage,
|
||||
"header_video": result.Artist.HeaderVideo,
|
||||
"listeners": result.Artist.Listeners,
|
||||
"provider_id": result.Artist.ProviderID,
|
||||
}
|
||||
@@ -3507,6 +3714,7 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": track.ResolvedCoverURL(),
|
||||
"preview_url": track.PreviewURL,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"total_tracks": track.TotalTracks,
|
||||
@@ -3742,13 +3950,29 @@ func GetStoreCategoriesJSON() (string, error) {
|
||||
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) == "" {
|
||||
return "", fmt.Errorf("invalid extension id")
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -3757,7 +3981,12 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) {
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
@@ -3822,9 +4051,12 @@ func callExtensionFunctionJSONWithRequestID(extensionID, functionName string, ti
|
||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||
return extension.%s();
|
||||
}
|
||||
if (typeof %s === 'function') {
|
||||
return %s();
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`, functionName, functionName)
|
||||
`, functionName, functionName, functionName, functionName)
|
||||
|
||||
jsStartedAt := time.Now()
|
||||
result, err := RunWithTimeoutContextAndRecover(requestCtx, vm, script, timeout)
|
||||
|
||||
@@ -31,6 +31,44 @@ func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadErrorClassificationDetectsVerificationRequired(t *testing.T) {
|
||||
cases := []string{
|
||||
"HTTP 401 for /tickets",
|
||||
"HTTP status 428: precondition required",
|
||||
"Verification required",
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := classifyDownloadErrorType(tc); got != "verification_required" {
|
||||
t.Fatalf("classifyDownloadErrorType(%q) = %q, want verification_required", tc, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProviderMetadataPrefersEnabledDeezerExtension(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := InitExtensionSystem(filepath.Join(dir, "extensions"), filepath.Join(dir, "data")); err != nil {
|
||||
t.Fatalf("InitExtensionSystem: %v", err)
|
||||
}
|
||||
CleanupExtensions()
|
||||
defer CleanupExtensions()
|
||||
|
||||
ext := newTestLoadedExtension(t, ExtensionTypeMetadataProvider)
|
||||
ext.ID = "deezer"
|
||||
ext.Manifest.Name = "deezer"
|
||||
manager := getExtensionManager()
|
||||
manager.mu.Lock()
|
||||
manager.extensions = map[string]*loadedExtension{ext.ID: ext}
|
||||
manager.mu.Unlock()
|
||||
|
||||
jsonText, err := GetProviderMetadataJSON("deezer", "album", "201")
|
||||
if err != nil {
|
||||
t.Fatalf("GetProviderMetadataJSON deezer album: %v", err)
|
||||
}
|
||||
if !strings.Contains(jsonText, "album-track") {
|
||||
t.Fatalf("expected enabled deezer extension metadata, got %s", jsonText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataDir := filepath.Join(dir, "data")
|
||||
@@ -390,10 +428,25 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
if catsJSON, err := GetStoreCategoriesJSON(); err != nil || !strings.Contains(catsJSON, "metadata") {
|
||||
t.Fatalf("GetStoreCategoriesJSON = %q/%v", catsJSON, err)
|
||||
}
|
||||
if dest, err := buildStoreExtensionDestPath(dir, "coverage/ext"); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
|
||||
if dest, err := buildStoreExtensionDestPath(
|
||||
dir,
|
||||
"coverage/ext",
|
||||
"https://registry.example.com/coverage.spotiflac-ext",
|
||||
); err != nil || !strings.HasSuffix(dest, ".spotiflac-ext") {
|
||||
t.Fatalf("buildStoreExtensionDestPath = %q/%v", dest, err)
|
||||
}
|
||||
if _, 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")
|
||||
}
|
||||
if err := ClearStoreCacheJSON(); err != nil {
|
||||
|
||||
@@ -15,7 +15,9 @@ import (
|
||||
const (
|
||||
extensionHealthDefaultTimeout = 4 * time.Second
|
||||
extensionHealthMaxBodyBytes = 64 * 1024
|
||||
extensionHealthDefaultCache = 60 * time.Second
|
||||
extensionHealthDefaultCache = 10 * time.Minute
|
||||
extensionHealthMinCache = 60 * time.Second
|
||||
extensionHealthUnknownCache = 2 * time.Minute
|
||||
)
|
||||
|
||||
type ExtensionHealthResult struct {
|
||||
@@ -58,6 +60,7 @@ func CheckExtensionHealthJSON(extensionID string) (string, error) {
|
||||
}
|
||||
|
||||
result := CheckExtensionHealth(ext)
|
||||
cacheExtensionHealthResult(ext, result)
|
||||
bytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -85,16 +88,31 @@ func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
|
||||
extensionHealthCacheMu.Unlock()
|
||||
|
||||
result := CheckExtensionHealth(ext)
|
||||
cacheExtensionHealthResult(ext, result)
|
||||
return result
|
||||
}
|
||||
|
||||
func cacheExtensionHealthResult(ext *loadedExtension, result ExtensionHealthResult) {
|
||||
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cacheKey := strings.TrimSpace(ext.ID)
|
||||
if cacheKey == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth)
|
||||
if result.Status == "unknown" && ttl > extensionHealthUnknownCache {
|
||||
ttl = extensionHealthUnknownCache
|
||||
}
|
||||
|
||||
extensionHealthCacheMu.Lock()
|
||||
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
|
||||
result: result,
|
||||
expiresAt: now.Add(ttl),
|
||||
expiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
extensionHealthCacheMu.Unlock()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
|
||||
@@ -149,6 +167,9 @@ func extensionHealthCacheTTL(checks []ExtensionHealthCheck) time.Duration {
|
||||
continue
|
||||
}
|
||||
checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second
|
||||
if checkTTL < extensionHealthMinCache {
|
||||
checkTTL = extensionHealthMinCache
|
||||
}
|
||||
if checkTTL < ttl {
|
||||
ttl = checkTTL
|
||||
}
|
||||
@@ -226,7 +247,11 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC
|
||||
resp, err := NewMetadataHTTPClient(timeout).Do(req)
|
||||
result.LatencyMs = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
result.Status = "offline"
|
||||
if isTransientExtensionHealthError(err) {
|
||||
result.Status = "unknown"
|
||||
} else {
|
||||
result.Status = "offline"
|
||||
}
|
||||
result.Error = err.Error()
|
||||
return result
|
||||
}
|
||||
@@ -262,6 +287,10 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC
|
||||
return result
|
||||
}
|
||||
|
||||
func isTransientExtensionHealthError(err error) bool {
|
||||
return isTransientNetworkError(err) || isConnectivityFailure(err)
|
||||
}
|
||||
|
||||
func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string) {
|
||||
if len(strings.TrimSpace(string(body))) == 0 {
|
||||
return "online", ""
|
||||
@@ -287,6 +316,9 @@ func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string
|
||||
case "degraded", "partial", "warning", "warn":
|
||||
return "degraded", rawStatus
|
||||
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
||||
if isTransientHealthStatusMessage(string(body)) {
|
||||
return "unknown", rawStatus
|
||||
}
|
||||
return "offline", rawStatus
|
||||
default:
|
||||
return "online", rawStatus
|
||||
@@ -327,42 +359,53 @@ func classifyExtensionHealthService(payload map[string]interface{}, serviceKey s
|
||||
|
||||
rawStatus, hasStatus := service["status"]
|
||||
okValue, hasOK := service["ok"].(bool)
|
||||
joinedMessage := strings.Join(messageParts, ": ")
|
||||
transient := isTransientHealthStatusMessage(detail) ||
|
||||
isTransientHealthStatusMessage(errText) ||
|
||||
isTransientHealthStatusMessage(label)
|
||||
|
||||
if statusCode, ok := healthNumber(rawStatus); ok {
|
||||
if statusCode >= 200 && statusCode < 300 {
|
||||
return "online", strings.Join(messageParts, ": "), true
|
||||
return "online", joinedMessage, true
|
||||
}
|
||||
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
|
||||
return "degraded", strings.Join(messageParts, ": "), true
|
||||
return "degraded", joinedMessage, true
|
||||
}
|
||||
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) {
|
||||
return "degraded", strings.Join(messageParts, ": "), true
|
||||
return "degraded", joinedMessage, true
|
||||
}
|
||||
if transient {
|
||||
return "unknown", joinedMessage, true
|
||||
}
|
||||
if hasOK {
|
||||
if okValue {
|
||||
return "online", strings.Join(messageParts, ": "), true
|
||||
return "online", joinedMessage, true
|
||||
}
|
||||
return "offline", strings.Join(messageParts, ": "), true
|
||||
return "offline", joinedMessage, true
|
||||
}
|
||||
if !hasStatus {
|
||||
return "unknown", strings.Join(messageParts, ": "), true
|
||||
return "unknown", joinedMessage, true
|
||||
}
|
||||
|
||||
statusString := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", rawStatus)))
|
||||
switch statusString {
|
||||
case "ok", "up", "online", "healthy", "operational":
|
||||
return "online", strings.Join(messageParts, ": "), true
|
||||
return "online", joinedMessage, true
|
||||
case "degraded", "partial", "warning", "warn":
|
||||
return "degraded", strings.Join(messageParts, ": "), true
|
||||
return "degraded", joinedMessage, true
|
||||
case "down", "offline", "error", "failed", "fail", "unhealthy":
|
||||
return "offline", strings.Join(messageParts, ": "), true
|
||||
return "offline", joinedMessage, true
|
||||
default:
|
||||
return "unknown", strings.Join(messageParts, ": "), true
|
||||
return "unknown", joinedMessage, true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,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) {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -27,6 +29,12 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) {
|
||||
if !isExtensionHealthAuthRequired(" unauthorized ") {
|
||||
t.Fatal("expected auth required")
|
||||
}
|
||||
if !isTransientExtensionHealthError(context.DeadlineExceeded) || !isTransientExtensionHealthError(&net.DNSError{IsTimeout: true}) {
|
||||
t.Fatal("expected timeout health errors to be transient")
|
||||
}
|
||||
if !isTransientExtensionHealthError(&net.DNSError{IsNotFound: true}) {
|
||||
t.Fatal("expected health transport lookup errors to be indeterminate")
|
||||
}
|
||||
|
||||
if result := CheckExtensionHealth(nil); result.Status != "offline" {
|
||||
t.Fatalf("nil health = %#v", result)
|
||||
|
||||
@@ -44,18 +44,24 @@ func compareVersions(v1, v2 string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func isExtensionPackagePath(filePath string) bool {
|
||||
lowerPath := strings.ToLower(filePath)
|
||||
return strings.HasSuffix(lowerPath, ".spotiflac-ext") || strings.HasSuffix(lowerPath, ".sflx")
|
||||
}
|
||||
|
||||
type loadedExtension struct {
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
runtime *extensionRuntime
|
||||
initialized bool
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
SourceDir string `json:"source_dir"`
|
||||
IconPath string `json:"icon_path"`
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
runtime *extensionRuntime
|
||||
indexProgram *goja.Program
|
||||
initialized bool
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
SourceDir string `json:"source_dir"`
|
||||
IconPath string `json:"icon_path"`
|
||||
}
|
||||
|
||||
func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
||||
@@ -118,7 +124,11 @@ func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||
}
|
||||
|
||||
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
|
||||
extensionsDir string
|
||||
dataDir string
|
||||
@@ -156,8 +166,14 @@ func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
}
|
||||
|
||||
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
||||
m.mutationMu.Lock()
|
||||
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)
|
||||
@@ -212,7 +228,7 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
||||
if exists {
|
||||
versionCompare := compareVersions(manifest.Version, existingVersion)
|
||||
if versionCompare > 0 {
|
||||
return m.UpgradeExtension(filePath)
|
||||
return m.upgradeExtensionLocked(filePath)
|
||||
} else if versionCompare == 0 {
|
||||
return nil, fmt.Errorf("extension '%s' v%s is already installed", existingDisplayName, existingVersion)
|
||||
} else {
|
||||
@@ -296,6 +312,7 @@ func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtens
|
||||
func initializeVMLocked(ext *loadedExtension) error {
|
||||
ext.VM = nil
|
||||
ext.runtime = nil
|
||||
ext.indexProgram = nil
|
||||
ext.initialized = false
|
||||
vm := goja.New()
|
||||
ext.VM = vm
|
||||
@@ -305,6 +322,11 @@ func initializeVMLocked(ext *loadedExtension) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read index.js: %w", err)
|
||||
}
|
||||
indexProgram, err := goja.Compile(indexPath, string(jsCode), false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compile extension code: %w", err)
|
||||
}
|
||||
ext.indexProgram = indexProgram
|
||||
|
||||
runtime := newExtensionRuntime(ext)
|
||||
ext.runtime = runtime
|
||||
@@ -331,7 +353,7 @@ func initializeVMLocked(ext *loadedExtension) error {
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
_, err = vm.RunString(string(jsCode))
|
||||
_, err = vm.RunProgram(indexProgram)
|
||||
if err != nil {
|
||||
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) {
|
||||
vm := goja.New()
|
||||
|
||||
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
||||
jsCode, err := os.ReadFile(indexPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
|
||||
indexProgram := ext.indexProgram
|
||||
if indexProgram == nil {
|
||||
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
||||
jsCode, err := os.ReadFile(indexPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read index.js: %w", err)
|
||||
}
|
||||
indexProgram, err = goja.Compile(indexPath, string(jsCode), false)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to compile extension code: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
runtime := &extensionRuntime{
|
||||
@@ -392,7 +421,7 @@ func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensio
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
if _, err := vm.RunString(string(jsCode)); err != nil {
|
||||
if _, err := vm.RunProgram(indexProgram); err != nil {
|
||||
runtime.closeStorageFlusher()
|
||||
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)
|
||||
}
|
||||
}
|
||||
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
|
||||
} else if isExtensionPackagePath(entry.Name()) {
|
||||
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
|
||||
if err != nil {
|
||||
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 {
|
||||
m.mutationMu.Lock()
|
||||
defer m.mutationMu.Unlock()
|
||||
|
||||
ext, err := m.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -756,8 +788,14 @@ func (m *extensionManager) RemoveExtension(extensionID string) error {
|
||||
|
||||
// Only allows upgrades (new version > current version), not downgrades
|
||||
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
||||
m.mutationMu.Lock()
|
||||
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)
|
||||
@@ -905,8 +943,8 @@ type ExtensionUpgradeInfo struct {
|
||||
}
|
||||
|
||||
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext file")
|
||||
if !isExtensionPackagePath(filePath) {
|
||||
return nil, fmt.Errorf("invalid file format: please select a .spotiflac-ext or .sflx file")
|
||||
}
|
||||
|
||||
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
|
||||
// message, open_auth_url, setting_updates without unwrapping a nested "result" key.
|
||||
actionNameLiteral := strconv.Quote(actionName)
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||
try {
|
||||
var result = extension.%s();
|
||||
if (result && typeof result.then === 'function') {
|
||||
return { success: true, pending: true, message: 'Action started' };
|
||||
}
|
||||
(function() {
|
||||
var actionName = %s;
|
||||
function runAction(fn) {
|
||||
try {
|
||||
var result = fn();
|
||||
if (result && typeof result.then === 'function') {
|
||||
return { success: true, pending: true, message: 'Action started' };
|
||||
}
|
||||
if (result !== null && result !== undefined && typeof result === 'object') {
|
||||
var isArr = false;
|
||||
if (typeof Array !== 'undefined' && Array.isArray) {
|
||||
@@ -1173,13 +1213,19 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) (
|
||||
}
|
||||
}
|
||||
return { success: true, result: result };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { success: false, error: 'Action function not found: %s' };
|
||||
})()
|
||||
`, actionName, actionName, actionName)
|
||||
if (typeof extension !== 'undefined' && extension && typeof extension[actionName] === 'function') {
|
||||
return runAction(function() { return extension[actionName](); });
|
||||
}
|
||||
if (actionName === 'completeGrant' && typeof session !== 'undefined' && session && typeof session.completeGrant === 'function') {
|
||||
return runAction(function() { return session.completeGrant(); });
|
||||
}
|
||||
return { success: false, error: 'Action function not found: ' + actionName };
|
||||
})()
|
||||
`, actionNameLiteral)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(vm, script, DefaultJSTimeout)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package gobackend
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -113,28 +114,49 @@ type ExtensionHealthCheck struct {
|
||||
Required bool `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
type SignedSessionEndpoints struct {
|
||||
Bootstrap string `json:"bootstrap,omitempty"`
|
||||
Challenge string `json:"challenge,omitempty"`
|
||||
Exchange string `json:"exchange,omitempty"`
|
||||
Refresh string `json:"refresh,omitempty"`
|
||||
}
|
||||
|
||||
type SignedSessionConfig struct {
|
||||
Namespace string `json:"namespace"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
AppVersion string `json:"appVersion,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
CallbackURL string `json:"callbackUrl,omitempty"`
|
||||
SchemeLabel string `json:"schemeLabel,omitempty"`
|
||||
HeaderPrefix string `json:"headerPrefix,omitempty"`
|
||||
TimeWindowSeconds int `json:"timeWindowSeconds,omitempty"`
|
||||
Endpoints SignedSessionEndpoints `json:"endpoints,omitempty"`
|
||||
}
|
||||
|
||||
type ExtensionManifest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Types []ExtensionType `json:"type"`
|
||||
Permissions ExtensionPermissions `json:"permissions"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
||||
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Types []ExtensionType `json:"type"`
|
||||
Permissions ExtensionPermissions `json:"permissions"`
|
||||
Settings []ExtensionSetting `json:"settings,omitempty"`
|
||||
QualityOptions []QualityOption `json:"qualityOptions,omitempty"`
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
SkipMetadataEnrichment bool `json:"skipMetadataEnrichment,omitempty"`
|
||||
SkipLyrics bool `json:"skipLyrics,omitempty"`
|
||||
StopProviderFallback bool `json:"stopProviderFallback,omitempty"`
|
||||
SkipBuiltInFallback bool `json:"skipBuiltInFallback,omitempty"`
|
||||
SearchBehavior *SearchBehaviorConfig `json:"searchBehavior,omitempty"`
|
||||
URLHandler *URLHandlerConfig `json:"urlHandler,omitempty"`
|
||||
TrackMatching *TrackMatchingConfig `json:"trackMatching,omitempty"`
|
||||
PostProcessing *PostProcessingConfig `json:"postProcessing,omitempty"`
|
||||
ServiceHealth []ExtensionHealthCheck `json:"serviceHealth,omitempty"`
|
||||
SignedSession *SignedSessionConfig `json:"signedSession,omitempty"`
|
||||
RequiredRuntimeFeatures []string `json:"requiredRuntimeFeatures,omitempty"`
|
||||
Capabilities map[string]interface{} `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
type ManifestValidationError struct {
|
||||
@@ -200,7 +222,6 @@ func (m *ExtensionManifest) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Select type requires options
|
||||
if setting.Type == SettingTypeSelect && len(setting.Options) == 0 {
|
||||
return &ManifestValidationError{
|
||||
Field: fmt.Sprintf("settings[%d].options", i),
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+423
-107
@@ -29,6 +29,7 @@ type ExtTrackMetadata struct {
|
||||
ExternalURL string `json:"external_urls,omitempty"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
Images string `json:"images,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TrackNumber int `json:"track_number,omitempty"`
|
||||
@@ -68,9 +69,12 @@ type ExtAlbumMetadata struct {
|
||||
Artists string `json:"artists"`
|
||||
ArtistID string `json:"artist_id,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
HeaderImage string `json:"header_image,omitempty"`
|
||||
HeaderVideo string `json:"header_video,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
AudioTraits []string `json:"audio_traits,omitempty"`
|
||||
Tracks []ExtTrackMetadata `json:"tracks"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
}
|
||||
@@ -80,6 +84,7 @@ type ExtArtistMetadata struct {
|
||||
Name string `json:"name"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
HeaderImage string `json:"header_image,omitempty"`
|
||||
HeaderVideo string `json:"header_video,omitempty"`
|
||||
Listeners int `json:"listeners,omitempty"`
|
||||
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
||||
Releases []ExtAlbumMetadata `json:"releases,omitempty"`
|
||||
@@ -473,6 +478,18 @@ func shouldAbortCancelledFallback(itemID string, err error) bool {
|
||||
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 {
|
||||
Strategy string `json:"strategy,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
@@ -483,14 +500,15 @@ type DownloadDecryptionInfo struct {
|
||||
}
|
||||
|
||||
type ExtDownloadResult struct {
|
||||
Success bool `json:"success"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
BitDepth int `json:"bit_depth,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
AudioCodec string `json:"audio_codec,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
BitDepth int `json:"bit_depth,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
AudioCodec string `json:"audio_codec,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
ErrorType string `json:"error_type,omitempty"`
|
||||
RetryAfterSeconds int `json:"retry_after_seconds,omitempty"`
|
||||
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
@@ -724,6 +742,32 @@ func gojaObjectStringMap(vm *goja.Runtime, obj *goja.Object, keys ...string) map
|
||||
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) {
|
||||
if gojaValueIsEmpty(value) {
|
||||
return 0, nil
|
||||
@@ -754,6 +798,7 @@ func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetada
|
||||
ExternalURL: gojaObjectString(obj, "external_urls", "externalUrls", "external_url", "externalUrl", "url"),
|
||||
DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"),
|
||||
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
||||
PreviewURL: gojaObjectString(obj, "preview_url", "previewUrl"),
|
||||
Images: gojaObjectString(obj, "images"),
|
||||
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
|
||||
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
|
||||
@@ -820,12 +865,147 @@ func parseExtensionAlbumValue(vm *goja.Runtime, value goja.Value) (ExtAlbumMetad
|
||||
Artists: gojaObjectString(obj, "artists"),
|
||||
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
|
||||
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl", "images"),
|
||||
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
|
||||
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
|
||||
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
|
||||
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
|
||||
AlbumType: gojaObjectString(obj, "album_type", "albumType"),
|
||||
AudioTraits: gojaObjectStringSlice(obj, "audio_traits", "audioTraits"),
|
||||
Tracks: tracks,
|
||||
ProviderID: gojaObjectString(obj, "provider_id", "providerId"),
|
||||
}, 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) {
|
||||
@@ -891,6 +1071,7 @@ func parseExtensionArtistValue(vm *goja.Runtime, value goja.Value) (ExtArtistMet
|
||||
Name: gojaObjectString(obj, "name"),
|
||||
ImageURL: gojaObjectString(obj, "image_url", "imageUrl"),
|
||||
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
|
||||
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
|
||||
Listeners: gojaObjectInt(obj, "listeners"),
|
||||
Albums: albums,
|
||||
Releases: releases,
|
||||
@@ -942,35 +1123,36 @@ func parseExtensionDownloadDecryptionValue(vm *goja.Runtime, value goja.Value) *
|
||||
func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDownloadResult {
|
||||
obj := value.ToObject(vm)
|
||||
return ExtDownloadResult{
|
||||
Success: gojaObjectBool(obj, "success"),
|
||||
FilePath: gojaObjectString(obj, "file_path", "filePath", "path"),
|
||||
AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"),
|
||||
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
|
||||
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
|
||||
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
|
||||
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
|
||||
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
|
||||
Title: gojaObjectString(obj, "title"),
|
||||
Artist: gojaObjectString(obj, "artist"),
|
||||
Album: gojaObjectString(obj, "album"),
|
||||
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
|
||||
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
|
||||
DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"),
|
||||
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
|
||||
TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"),
|
||||
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
|
||||
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
||||
ISRC: gojaObjectString(obj, "isrc"),
|
||||
Genre: gojaObjectString(obj, "genre"),
|
||||
Label: gojaObjectString(obj, "label"),
|
||||
Copyright: gojaObjectString(obj, "copyright"),
|
||||
Composer: gojaObjectString(obj, "composer"),
|
||||
LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"),
|
||||
DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"),
|
||||
Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")),
|
||||
ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"),
|
||||
OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"),
|
||||
ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"),
|
||||
Success: gojaObjectBool(obj, "success"),
|
||||
FilePath: gojaObjectString(obj, "file_path", "filePath", "path"),
|
||||
AlreadyExists: gojaObjectBool(obj, "already_exists", "alreadyExists"),
|
||||
BitDepth: gojaObjectInt(obj, "bit_depth", "bitDepth"),
|
||||
SampleRate: gojaObjectInt(obj, "sample_rate", "sampleRate"),
|
||||
AudioCodec: gojaObjectString(obj, "audio_codec", "audioCodec", "codec"),
|
||||
ErrorMessage: gojaObjectString(obj, "error_message", "errorMessage", "error"),
|
||||
ErrorType: gojaObjectString(obj, "error_type", "errorType"),
|
||||
RetryAfterSeconds: gojaObjectInt(obj, "retry_after_seconds", "retryAfterSeconds"),
|
||||
Title: gojaObjectString(obj, "title"),
|
||||
Artist: gojaObjectString(obj, "artist"),
|
||||
Album: gojaObjectString(obj, "album"),
|
||||
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
|
||||
TrackNumber: gojaObjectInt(obj, "track_number", "trackNumber"),
|
||||
DiscNumber: gojaObjectInt(obj, "disc_number", "discNumber"),
|
||||
TotalTracks: gojaObjectInt(obj, "total_tracks", "totalTracks"),
|
||||
TotalDiscs: gojaObjectInt(obj, "total_discs", "totalDiscs"),
|
||||
ReleaseDate: gojaObjectString(obj, "release_date", "releaseDate"),
|
||||
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
||||
ISRC: gojaObjectString(obj, "isrc"),
|
||||
Genre: gojaObjectString(obj, "genre"),
|
||||
Label: gojaObjectString(obj, "label"),
|
||||
Copyright: gojaObjectString(obj, "copyright"),
|
||||
Composer: gojaObjectString(obj, "composer"),
|
||||
LyricsLRC: gojaObjectString(obj, "lyrics_lrc", "lyricsLrc"),
|
||||
DecryptionKey: gojaObjectString(obj, "decryption_key", "decryptionKey"),
|
||||
Decryption: parseExtensionDownloadDecryptionValue(vm, gojaObjectValue(obj, "decryption")),
|
||||
ActualExtension: gojaObjectString(obj, "actual_extension", "actualExtension"),
|
||||
OutputExtension: gojaObjectString(obj, "output_extension", "outputExtension"),
|
||||
ActualContainer: gojaObjectString(obj, "actual_container", "actualContainer", "container"),
|
||||
RequiresContainerConversion: gojaObjectBool(
|
||||
obj,
|
||||
"requires_container_conversion",
|
||||
@@ -982,9 +1164,11 @@ func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDo
|
||||
func parseExtensionURLHandleValue(vm *goja.Runtime, value goja.Value) (ExtURLHandleResult, error) {
|
||||
obj := value.ToObject(vm)
|
||||
handleResult := ExtURLHandleResult{
|
||||
Type: gojaObjectString(obj, "type"),
|
||||
Name: gojaObjectString(obj, "name"),
|
||||
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
||||
Type: gojaObjectString(obj, "type"),
|
||||
Name: gojaObjectString(obj, "name"),
|
||||
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
||||
HeaderImage: gojaObjectString(obj, "header_image", "headerImage"),
|
||||
HeaderVideo: gojaObjectString(obj, "header_video", "headerVideo"),
|
||||
}
|
||||
|
||||
if trackValue := gojaObjectValue(obj, "track"); !gojaValueIsEmpty(trackValue) {
|
||||
@@ -2135,6 +2319,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
var lastErrType string
|
||||
var lastRetryAfterSeconds int
|
||||
var stopProviderFallback bool
|
||||
var sourceExtensionLocked bool
|
||||
var sourceExtensionAvailability *ExtAvailabilityResult
|
||||
@@ -2416,15 +2602,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
resp.Composer = req.Composer
|
||||
}
|
||||
|
||||
if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) {
|
||||
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)
|
||||
}
|
||||
embedExtensionDownloadMetadata(resp, req, alreadyExists)
|
||||
|
||||
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
||||
indexISRC := strings.TrimSpace(resp.ISRC)
|
||||
@@ -2449,11 +2627,24 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}, nil
|
||||
}
|
||||
lastErr = err
|
||||
lastErrType = ""
|
||||
} else if 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)
|
||||
|
||||
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 sourceExtensionLocked {
|
||||
GoLog("[DownloadWithExtensionFallback] Source extension %s requested skip_fallback, not trying other providers\n", req.Source)
|
||||
@@ -2461,10 +2652,11 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] stopProviderFallback is true, not trying other providers\n")
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Download failed: " + lastErr.Error(),
|
||||
ErrorType: "extension_error",
|
||||
Service: req.Source,
|
||||
Success: false,
|
||||
Error: "Download failed: " + lastErr.Error(),
|
||||
ErrorType: firstNonEmptyString(lastErrType, "extension_error"),
|
||||
RetryAfterSeconds: lastRetryAfterSeconds,
|
||||
Service: req.Source,
|
||||
}, nil
|
||||
}
|
||||
} else {
|
||||
@@ -2483,11 +2675,13 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
if providerID == "" {
|
||||
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
|
||||
}
|
||||
|
||||
if !isExtensionFallbackAllowed(providerID) {
|
||||
if providerID != selectedProvider && !isExtensionFallbackAllowed(providerID) {
|
||||
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (not enabled for fallback)\n", providerID)
|
||||
continue
|
||||
}
|
||||
@@ -2516,6 +2710,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
|
||||
if err != nil {
|
||||
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 {
|
||||
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after availability check\n", providerID)
|
||||
@@ -2530,7 +2733,30 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
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 != "" {
|
||||
normalized := float64(percent) / 100.0
|
||||
if normalized < 0 {
|
||||
@@ -2574,15 +2800,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
applyExtensionRequestFallbacks(&resp, req)
|
||||
|
||||
if !alreadyExists && req.EmbedMetadata && (req.Genre != "" || req.Label != "") && canEmbedGenreLabel(normalizedResult.FilePath) {
|
||||
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)
|
||||
}
|
||||
embedExtensionDownloadMetadata(resp, req, alreadyExists)
|
||||
|
||||
if !alreadyExists && !isFDOutput(req.OutputFD) && strings.TrimSpace(req.OutputDir) != "" {
|
||||
indexISRC := strings.TrimSpace(resp.ISRC)
|
||||
@@ -2607,10 +2825,31 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}, nil
|
||||
}
|
||||
lastErr = err
|
||||
lastErrType = ""
|
||||
} else if 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)
|
||||
|
||||
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 {
|
||||
GoLog("[DownloadWithExtensionFallback] %s requested skip_fallback after download failure\n", providerID)
|
||||
return buildExtensionFallbackStoppedResponse(providerID, availability, lastErr), nil
|
||||
@@ -2619,14 +2858,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
errorType := classifyDownloadErrorType(lastErr.Error())
|
||||
errorType := firstNonEmptyString(lastErrType, classifyDownloadErrorType(lastErr.Error()))
|
||||
if errorType == "unknown" {
|
||||
errorType = "not_found"
|
||||
}
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "All providers failed. Last error: " + lastErr.Error(),
|
||||
ErrorType: errorType,
|
||||
Success: false,
|
||||
Error: "All providers failed. Last error: " + lastErr.Error(),
|
||||
ErrorType: errorType,
|
||||
RetryAfterSeconds: lastRetryAfterSeconds,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2643,21 +2883,22 @@ func buildOutputPath(req DownloadRequest) string {
|
||||
}
|
||||
|
||||
metadata := map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"album_artist": req.AlbumArtist,
|
||||
"track": req.TrackNumber,
|
||||
"track_number": req.TrackNumber,
|
||||
"total_tracks": req.TotalTracks,
|
||||
"disc": req.DiscNumber,
|
||||
"disc_number": req.DiscNumber,
|
||||
"total_discs": req.TotalDiscs,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"release_date": req.ReleaseDate,
|
||||
"isrc": req.ISRC,
|
||||
"composer": req.Composer,
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"album_artist": req.AlbumArtist,
|
||||
"track": req.TrackNumber,
|
||||
"track_number": req.TrackNumber,
|
||||
"total_tracks": req.TotalTracks,
|
||||
"playlist_position": req.PlaylistPosition,
|
||||
"disc": req.DiscNumber,
|
||||
"disc_number": req.DiscNumber,
|
||||
"total_discs": req.TotalDiscs,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"release_date": req.ReleaseDate,
|
||||
"isrc": req.ISRC,
|
||||
"composer": req.Composer,
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||
@@ -2702,21 +2943,22 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri
|
||||
AddAllowedDownloadDir(tempDir)
|
||||
|
||||
metadata := map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"album_artist": req.AlbumArtist,
|
||||
"track": req.TrackNumber,
|
||||
"track_number": req.TrackNumber,
|
||||
"total_tracks": req.TotalTracks,
|
||||
"disc": req.DiscNumber,
|
||||
"disc_number": req.DiscNumber,
|
||||
"total_discs": req.TotalDiscs,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"release_date": req.ReleaseDate,
|
||||
"isrc": req.ISRC,
|
||||
"composer": req.Composer,
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"album_artist": req.AlbumArtist,
|
||||
"track": req.TrackNumber,
|
||||
"track_number": req.TrackNumber,
|
||||
"total_tracks": req.TotalTracks,
|
||||
"playlist_position": req.PlaylistPosition,
|
||||
"disc": req.DiscNumber,
|
||||
"disc_number": req.DiscNumber,
|
||||
"total_discs": req.TotalDiscs,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"release_date": req.ReleaseDate,
|
||||
"isrc": req.ISRC,
|
||||
"composer": req.Composer,
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||
@@ -2750,6 +2992,78 @@ func canEmbedGenreLabel(filePath string) bool {
|
||||
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) {
|
||||
return p.customSearch(query, options, "", "")
|
||||
}
|
||||
@@ -2873,13 +3187,15 @@ func (p *extensionProviderWrapper) customSearch(query string, options map[string
|
||||
}
|
||||
|
||||
type ExtURLHandleResult struct {
|
||||
Type string `json:"type"`
|
||||
Track *ExtTrackMetadata `json:"track,omitempty"`
|
||||
Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
|
||||
Album *ExtAlbumMetadata `json:"album,omitempty"`
|
||||
Artist *ExtArtistMetadata `json:"artist,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Track *ExtTrackMetadata `json:"track,omitempty"`
|
||||
Tracks []ExtTrackMetadata `json:"tracks,omitempty"`
|
||||
Album *ExtAlbumMetadata `json:"album,omitempty"`
|
||||
Artist *ExtArtistMetadata `json:"artist,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
HeaderImage string `json:"header_image,omitempty"`
|
||||
HeaderVideo string `json:"header_video,omitempty"`
|
||||
}
|
||||
|
||||
func (p *extensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, error) {
|
||||
|
||||
@@ -8,11 +8,35 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// allowPrivateNetworkAccess, when enabled, disables the SSRF guard that blocks
|
||||
// requests resolving to private/local/loopback addresses. This is opt-in and
|
||||
// intended for users who route the app's traffic through a local proxy or
|
||||
// custom DNS (e.g. a local mirror of api.zarz.moe). Disabled by default.
|
||||
var allowPrivateNetworkAccess atomic.Bool
|
||||
|
||||
// SetAllowPrivateNetwork toggles whether extensions and built-in network code
|
||||
// are permitted to reach private/local network targets. Exposed to the Flutter
|
||||
// layer via the platform bridge.
|
||||
func SetAllowPrivateNetwork(allowed bool) {
|
||||
allowPrivateNetworkAccess.Store(allowed)
|
||||
if allowed {
|
||||
GoLog("[HTTP] Private/local network access ENABLED (SSRF guard relaxed)\n")
|
||||
} else {
|
||||
GoLog("[HTTP] Private/local network access disabled (default)\n")
|
||||
}
|
||||
}
|
||||
|
||||
// IsPrivateNetworkAllowed reports the current state of the private-network guard.
|
||||
func IsPrivateNetworkAllowed() bool {
|
||||
return allowPrivateNetworkAccess.Load()
|
||||
}
|
||||
|
||||
const DefaultJSTimeout = 30 * time.Second
|
||||
|
||||
var (
|
||||
@@ -303,6 +327,12 @@ func (e *RedirectBlockedError) Error() string {
|
||||
}
|
||||
|
||||
func isPrivateIP(host string) bool {
|
||||
// Opt-in escape hatch: when the user has enabled private/local network
|
||||
// access, treat every host as public so local proxies / custom DNS work.
|
||||
if allowPrivateNetworkAccess.Load() {
|
||||
return false
|
||||
}
|
||||
|
||||
hostLower := strings.ToLower(strings.TrimSpace(host))
|
||||
if hostLower == "" {
|
||||
return false
|
||||
@@ -465,6 +495,15 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
authObj.Set("exchangeCodeWithPKCE", r.authExchangeCodeWithPKCE)
|
||||
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.Set("download", r.fileDownload)
|
||||
fileObj.Set("exists", r.fileExists)
|
||||
@@ -504,6 +543,7 @@ func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
|
||||
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
|
||||
utilsObj.Set("decryptCTRSegments", r.decryptCTRSegments)
|
||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
||||
utilsObj.Set("appVersion", r.appVersion)
|
||||
|
||||
@@ -158,6 +158,11 @@ func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
|
||||
cloned := make([]byte, len(value))
|
||||
copy(cloned, value)
|
||||
return cloned, nil
|
||||
case goja.ArrayBuffer:
|
||||
src := value.Bytes()
|
||||
cloned := make([]byte, len(src))
|
||||
copy(cloned, src)
|
||||
return cloned, nil
|
||||
case []interface{}:
|
||||
decoded := make([]byte, len(value))
|
||||
for i, item := range value {
|
||||
@@ -279,7 +284,9 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
if parsedOptions.Mode != "cbc" {
|
||||
switch parsedOptions.Mode {
|
||||
case "cbc", "ctr":
|
||||
default:
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
|
||||
@@ -303,37 +310,49 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
|
||||
}
|
||||
|
||||
if len(parsedOptions.IV) != block.BlockSize() {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("iv must be %d bytes for %s", block.BlockSize(), parsedOptions.Algorithm),
|
||||
})
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
}
|
||||
ivLabel := "iv"
|
||||
if parsedOptions.Mode == "ctr" {
|
||||
ivLabel = "iv (counter)"
|
||||
}
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("%s must be %d bytes for %s", ivLabel, block.BlockSize(), parsedOptions.Algorithm),
|
||||
})
|
||||
}
|
||||
|
||||
var output []byte
|
||||
if parsedOptions.Mode == "ctr" {
|
||||
// CTR is a stream mode: encryption and decryption are identical,
|
||||
// require no padding, and accept arbitrary input lengths.
|
||||
output = make([]byte, len(inputData))
|
||||
cipher.NewCTR(block, parsedOptions.IV).XORKeyStream(output, inputData)
|
||||
} else {
|
||||
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)
|
||||
@@ -358,3 +377,158 @@ func (r *extensionRuntime) encryptBlockCipher(call goja.FunctionCall) goja.Value
|
||||
func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value {
|
||||
return r.transformBlockCipher(call, true)
|
||||
}
|
||||
|
||||
// decryptCTRSegments decrypts many independently-IV'd AES-CTR segments inside a
|
||||
// single buffer in one host call. This exists to avoid thousands of JS->Go
|
||||
// bridge crossings when an extension decrypts per-sample CENC media (each
|
||||
// sample has its own IV/counter and cannot be merged into one stream).
|
||||
//
|
||||
// It is a generic primitive: any extension can use it for "one buffer, many
|
||||
// CTR segments" workloads, not just Apple CENC.
|
||||
//
|
||||
// For best performance, pass the buffer as an ArrayBuffer/Uint8Array and set
|
||||
// outputEncoding:"bytes" to get an ArrayBuffer back. This avoids base64
|
||||
// encode/decode of the (potentially multi-MB) payload entirely, which is the
|
||||
// dominant cost under the goja interpreter.
|
||||
//
|
||||
// JS signature:
|
||||
// utils.decryptCTRSegments(data, {
|
||||
// algorithm: "aes", // optional, default "aes"
|
||||
// key: "<hex>", keyEncoding: "hex",
|
||||
// segments: [ { offset: <int>, size: <int>, iv: "<base64>" }, ... ],
|
||||
// ivEncoding: "base64", // encoding of each segment.iv, default base64
|
||||
// inputEncoding: "bytes", // "bytes" for ArrayBuffer/Uint8Array, else base64/hex
|
||||
// outputEncoding: "bytes" // "bytes" -> ArrayBuffer; else base64/hex string
|
||||
// })
|
||||
// Returns { success, data, segments_processed } or { success:false, error }.
|
||||
func (r *extensionRuntime) decryptCTRSegments(call goja.FunctionCall) goja.Value {
|
||||
fail := func(msg string) goja.Value {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": msg,
|
||||
})
|
||||
}
|
||||
|
||||
if len(call.Arguments) < 2 {
|
||||
return fail("data and options are required")
|
||||
}
|
||||
|
||||
options := parseRuntimeOptionsArgument(call, 1)
|
||||
if options == nil {
|
||||
return fail("options object is required")
|
||||
}
|
||||
|
||||
algorithm := strings.ToLower(runtimeOptionString(options, "algorithm", "aes"))
|
||||
inputEncoding := strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64"))
|
||||
outputEncoding := strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64"))
|
||||
ivEncoding := strings.ToLower(runtimeOptionString(options, "ivEncoding", "base64"))
|
||||
|
||||
key, err := decodeRuntimeBytesString(
|
||||
runtimeOptionString(options, "key", ""),
|
||||
runtimeOptionString(options, "keyEncoding", "hex"),
|
||||
)
|
||||
if err != nil {
|
||||
return fail(fmt.Sprintf("invalid key: %v", err))
|
||||
}
|
||||
if len(key) == 0 {
|
||||
return fail("key is required")
|
||||
}
|
||||
|
||||
var block cipher.Block
|
||||
switch algorithm {
|
||||
case "aes":
|
||||
block, err = aes.NewCipher(key)
|
||||
case "blowfish":
|
||||
block, err = blowfish.NewCipher(key)
|
||||
default:
|
||||
return fail("unsupported algorithm: " + algorithm)
|
||||
}
|
||||
if err != nil {
|
||||
return fail(err.Error())
|
||||
}
|
||||
blockSize := block.BlockSize()
|
||||
|
||||
// Decode the payload. For "bytes" input we operate on the raw []byte
|
||||
// (ArrayBuffer/Uint8Array) without any base64 round-trip.
|
||||
var data []byte
|
||||
if inputEncoding == "bytes" || inputEncoding == "raw" {
|
||||
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), "")
|
||||
if err != nil {
|
||||
return fail("invalid byte payload: " + err.Error())
|
||||
}
|
||||
} else {
|
||||
data, err = decodeRuntimeBytesValue(call.Arguments[0].Export(), inputEncoding)
|
||||
if err != nil {
|
||||
return fail(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
rawSegments, ok := options["segments"]
|
||||
if !ok || rawSegments == nil {
|
||||
return fail("segments array is required")
|
||||
}
|
||||
segments, ok := rawSegments.([]interface{})
|
||||
if !ok {
|
||||
return fail("segments must be an array")
|
||||
}
|
||||
|
||||
processed := 0
|
||||
for i, rawSeg := range segments {
|
||||
seg, ok := rawSeg.(map[string]interface{})
|
||||
if !ok {
|
||||
return fail(fmt.Sprintf("segment %d is not an object", i))
|
||||
}
|
||||
|
||||
offset := int(runtimeOptionInt64(seg, "offset", -1))
|
||||
size := int(runtimeOptionInt64(seg, "size", -1))
|
||||
if offset < 0 || size < 0 {
|
||||
return fail(fmt.Sprintf("segment %d has invalid offset/size", i))
|
||||
}
|
||||
if size == 0 {
|
||||
continue
|
||||
}
|
||||
if offset+size > len(data) {
|
||||
return fail(fmt.Sprintf("segment %d out of bounds (offset=%d size=%d len=%d)", i, offset, size, len(data)))
|
||||
}
|
||||
|
||||
iv, err := decodeRuntimeBytesString(runtimeOptionString(seg, "iv", ""), ivEncoding)
|
||||
if err != nil {
|
||||
return fail(fmt.Sprintf("segment %d has invalid iv: %v", i, err))
|
||||
}
|
||||
if len(iv) != blockSize {
|
||||
// Accept short IVs by left-aligning into a block-sized counter
|
||||
// (CENC commonly uses 8-byte IVs for a 16-byte AES counter).
|
||||
if len(iv) > blockSize {
|
||||
return fail(fmt.Sprintf("segment %d iv longer than block size (%d > %d)", i, len(iv), blockSize))
|
||||
}
|
||||
padded := make([]byte, blockSize)
|
||||
copy(padded, iv)
|
||||
iv = padded
|
||||
}
|
||||
|
||||
segData := data[offset : offset+size]
|
||||
cipher.NewCTR(block, iv).XORKeyStream(segData, segData)
|
||||
processed++
|
||||
}
|
||||
|
||||
// Return raw bytes as an ArrayBuffer when requested (zero-copy-ish, no
|
||||
// base64). Otherwise fall back to an encoded string.
|
||||
if outputEncoding == "bytes" || outputEncoding == "raw" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": r.vm.NewArrayBuffer(data),
|
||||
"segments_processed": processed,
|
||||
})
|
||||
}
|
||||
|
||||
encoded, err := encodeRuntimeBytes(data, outputEncoding)
|
||||
if err != nil {
|
||||
return fail(err.Error())
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": encoded,
|
||||
"segments_processed": processed,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -183,3 +183,303 @@ func TestExtensionRuntime_BlockCipherCBCSupportsAES(t *testing.T) {
|
||||
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
|
||||
contentRange := probeResp.Header.Get("Content-Range")
|
||||
if contentRange != "" {
|
||||
// Format: "bytes 0-1/12345"
|
||||
if idx := strings.LastIndex(contentRange, "/"); idx >= 0 {
|
||||
sizeStr := contentRange[idx+1:]
|
||||
if sizeStr != "*" {
|
||||
@@ -457,7 +456,6 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
|
||||
break // Success
|
||||
}
|
||||
|
||||
// Non-success status
|
||||
io.Copy(io.Discard, chunkResp.Body)
|
||||
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)
|
||||
for {
|
||||
nr, er := chunkResp.Body.Read(buf)
|
||||
@@ -663,7 +660,6 @@ func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
|
||||
"error": "offset must be >= 0",
|
||||
})
|
||||
}
|
||||
|
||||
file, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
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
|
||||
}
|
||||
|
||||
func (s *extensionStore) downloadExtension(extensionID string, destPath string) error {
|
||||
func (s *extensionStore) findExtension(extensionID string) (*storeExtension, error) {
|
||||
registry, err := s.fetchRegistry(false)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ext *storeExtension
|
||||
for _, e := range registry.Extensions {
|
||||
if e.ID == extensionID {
|
||||
ext = &e
|
||||
break
|
||||
ext := e
|
||||
return &ext, nil
|
||||
}
|
||||
}
|
||||
|
||||
if ext == nil {
|
||||
return fmt.Errorf("extension %s not found in store", extensionID)
|
||||
return nil, 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 {
|
||||
|
||||
+15
-1
@@ -13,7 +13,7 @@ import (
|
||||
var (
|
||||
invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
||||
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:([^{}]+)\}`)
|
||||
yearPattern = regexp.MustCompile(`\d{4}`)
|
||||
)
|
||||
@@ -99,6 +99,11 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
||||
"{album}": getString(metadata, "album"),
|
||||
"{track}": formatTrackNumber(getInt(metadata, "track")),
|
||||
"{track_raw}": formatRawNumber(getInt(metadata, "track")),
|
||||
"{playlist_position}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||
"{playlist position}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||
"{playlistPosition}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||
"{position}": formatTrackNumber(getPlaylistPosition(metadata)),
|
||||
"{playlist_position_raw}": formatRawNumber(getPlaylistPosition(metadata)),
|
||||
"{year}": yearValue,
|
||||
"{date}": dateValue,
|
||||
"{disc}": formatDiscNumber(getInt(metadata, "disc")),
|
||||
@@ -120,6 +125,9 @@ func replaceFormattedNumberPlaceholders(template string, metadata map[string]int
|
||||
}
|
||||
|
||||
number := getInt(metadata, parts[1])
|
||||
if parts[1] == "playlist_position" || parts[1] == "playlistPosition" || parts[1] == "position" {
|
||||
number = getPlaylistPosition(metadata)
|
||||
}
|
||||
width, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return ""
|
||||
@@ -177,6 +185,8 @@ func getInt(m map[string]interface{}, key string) int {
|
||||
candidateKeys = append(candidateKeys, "track_number")
|
||||
case "disc":
|
||||
candidateKeys = append(candidateKeys, "disc_number")
|
||||
case "playlist_position", "playlistPosition", "playlist position", "position":
|
||||
candidateKeys = append(candidateKeys, "playlistPosition", "playlist position", "position")
|
||||
}
|
||||
|
||||
for _, candidate := range candidateKeys {
|
||||
@@ -200,6 +210,10 @@ func getInt(m map[string]interface{}, key string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func getPlaylistPosition(metadata map[string]interface{}) int {
|
||||
return getInt(metadata, "playlist_position")
|
||||
}
|
||||
|
||||
func formatTrackNumber(n int) string {
|
||||
if n <= 0 {
|
||||
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) {
|
||||
metadata := map[string]interface{}{
|
||||
"artist": "Artist Name",
|
||||
|
||||
+11
-11
@@ -5,25 +5,25 @@ go 1.25.0
|
||||
toolchain go1.25.9
|
||||
|
||||
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/flacvorbis/v2 v2.0.2
|
||||
github.com/go-flac/go-flac/v2 v2.0.4
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/mobile v0.0.0-20260529142300-ecb4cd65260a
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/text v0.38.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.2.2 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect
|
||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
)
|
||||
|
||||
+26
-26
@@ -1,13 +1,13 @@
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
|
||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dlclark/regexp2/v2 v2.2.2 h1:MYWvNYw8okuqNhwTYO587EZMiDruVa2vhV6fsGpfya0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dop251/goja v0.0.0-20260618133527-c9b2ea77db59 h1:DjKLmvKK9u15djHZ88N8M0DhgnHVgJJ8bnEe0h7Lga8=
|
||||
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/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||
@@ -16,10 +16,12 @@ 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-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/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M=
|
||||
github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE=
|
||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -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/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/mobile v0.0.0-20260529142300-ecb4cd65260a h1:sEcsLeiCTTaHGWn+v81+PLAOzzOA9wmzNRqr1WfCmVY=
|
||||
golang.org/x/mobile v0.0.0-20260529142300-ecb4cd65260a/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2 h1:zoM1gIKhVkcQNm43kad8OHLgPNoJ12xIqmxHtKr8Mug=
|
||||
golang.org/x/mobile v0.0.0-20260611195102-4dd8f1dbf5d2/go.mod h1:QGMqsqLn6orFQ/ksqYMf+Fa33Soa1vPoHEd0Pj7N+lQ=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+117
-73
@@ -1,7 +1,9 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -437,101 +439,143 @@ func (e *ISPBlockingError) Error() string {
|
||||
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 {
|
||||
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)
|
||||
errStr := strings.ToLower(err.Error())
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) {
|
||||
if urlErr.Timeout() {
|
||||
return "Connection timed out - ISP may be blocking access"
|
||||
}
|
||||
if urlErr.Err != nil {
|
||||
if reason := connectivityFailureReason(urlErr.Err); reason != "" {
|
||||
return reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
if dnsErr.IsNotFound || dnsErr.IsTemporary {
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "DNS resolution failed - domain may be blocked by ISP",
|
||||
OriginalErr: err,
|
||||
}
|
||||
if dnsErr.IsNotFound || dnsErr.IsTimeout || dnsErr.IsTemporary {
|
||||
return "DNS resolution failed - domain may be blocked by ISP"
|
||||
}
|
||||
}
|
||||
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) {
|
||||
if opErr.Op == "dial" {
|
||||
var syscallErr syscall.Errno
|
||||
if errors.As(opErr.Err, &syscallErr) {
|
||||
switch syscallErr {
|
||||
case syscall.ECONNREFUSED:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Connection refused - port may be blocked by ISP/firewall",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.ECONNRESET:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Connection reset - ISP may be intercepting traffic",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.ETIMEDOUT:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Connection timed out - ISP may be blocking access",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.ENETUNREACH:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Network unreachable - ISP may be blocking route",
|
||||
OriginalErr: err,
|
||||
}
|
||||
case syscall.EHOSTUNREACH:
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "Host unreachable - ISP may be blocking destination",
|
||||
OriginalErr: err,
|
||||
}
|
||||
}
|
||||
if opErr.Timeout() {
|
||||
return "Connection timed out - ISP may be blocking access"
|
||||
}
|
||||
var errno syscall.Errno
|
||||
if errors.As(opErr.Err, &errno) {
|
||||
switch errno {
|
||||
case syscall.ECONNREFUSED:
|
||||
return "Connection refused - port may be blocked by ISP/firewall"
|
||||
case syscall.ECONNRESET:
|
||||
return "Connection reset - ISP may be intercepting traffic"
|
||||
case syscall.ETIMEDOUT:
|
||||
return "Connection timed out - ISP may be blocking access"
|
||||
case syscall.ENETUNREACH:
|
||||
return "Network unreachable - ISP may be blocking route"
|
||||
case syscall.EHOSTUNREACH:
|
||||
return "Host unreachable - ISP may be blocking destination"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tlsErr *tls.RecordHeaderError
|
||||
if errors.As(err, &tlsErr) {
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic",
|
||||
OriginalErr: err,
|
||||
return "TLS handshake failed - ISP may be intercepting HTTPS traffic"
|
||||
}
|
||||
|
||||
var certErr x509.CertificateInvalidError
|
||||
if errors.As(err, &certErr) {
|
||||
return "Certificate error - ISP may be using MITM proxy"
|
||||
}
|
||||
var hostnameErr x509.HostnameError
|
||||
if errors.As(err, &hostnameErr) {
|
||||
return "Certificate error - ISP may be using MITM proxy"
|
||||
}
|
||||
var unknownAuth x509.UnknownAuthorityError
|
||||
if errors.As(err, &unknownAuth) {
|
||||
return "Certificate error - ISP may be using MITM proxy"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isTLSHandshakeOrResetError reports TLS handshake/cert failures and TCP resets
|
||||
// that should trigger a Chrome fingerprint retry.
|
||||
func isTLSHandshakeOrResetError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var recordErr *tls.RecordHeaderError
|
||||
if errors.As(err, &recordErr) {
|
||||
return true
|
||||
}
|
||||
var certErr x509.CertificateInvalidError
|
||||
if errors.As(err, &certErr) {
|
||||
return true
|
||||
}
|
||||
var hostnameErr x509.HostnameError
|
||||
if errors.As(err, &hostnameErr) {
|
||||
return true
|
||||
}
|
||||
var unknownAuth x509.UnknownAuthorityError
|
||||
if errors.As(err, &unknownAuth) {
|
||||
return true
|
||||
}
|
||||
var opErr *net.OpError
|
||||
if errors.As(err, &opErr) {
|
||||
var errno syscall.Errno
|
||||
if errors.As(opErr.Err, &errno) && errno == syscall.ECONNRESET {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
blockingPatterns := []struct {
|
||||
pattern string
|
||||
reason string
|
||||
}{
|
||||
{"connection reset by peer", "Connection reset - ISP may be intercepting traffic"},
|
||||
{"connection refused", "Connection refused - port may be blocked"},
|
||||
{"no such host", "DNS lookup failed - domain may be blocked by ISP"},
|
||||
{"i/o timeout", "Connection timed out - ISP may be blocking access"},
|
||||
{"network is unreachable", "Network unreachable - ISP may be blocking route"},
|
||||
{"tls: ", "TLS error - ISP may be intercepting HTTPS traffic"},
|
||||
{"certificate", "Certificate error - ISP may be using MITM proxy"},
|
||||
{"eof", "Connection closed unexpectedly - ISP may be blocking"},
|
||||
{"context deadline exceeded", "Request timed out - ISP may be throttling"},
|
||||
func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, bp := range blockingPatterns {
|
||||
if strings.Contains(errStr, bp.pattern) {
|
||||
return &ISPBlockingError{
|
||||
Domain: domain,
|
||||
Reason: bp.reason,
|
||||
OriginalErr: err,
|
||||
}
|
||||
}
|
||||
reason := connectivityFailureReason(err)
|
||||
if reason == "" {
|
||||
return nil
|
||||
}
|
||||
return &ISPBlockingError{
|
||||
Domain: extractDomain(requestURL),
|
||||
Reason: reason,
|
||||
OriginalErr: err,
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -131,15 +133,24 @@ func TestHTTPUtilityHelpers(t *testing.T) {
|
||||
if getRetryAfterDuration(&http.Response{Header: http.Header{"Retry-After": []string{"bad"}}}) != 0 {
|
||||
t.Fatal("invalid retry-after should be zero")
|
||||
}
|
||||
if isp := IsISPBlocking(errors.New("connection reset by peer"), "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") {
|
||||
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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
if !isTransientNetworkError(context.DeadlineExceeded) || isTransientNetworkError(&net.DNSError{IsNotFound: true}) {
|
||||
t.Fatal("isTransientNetworkError mismatch")
|
||||
}
|
||||
if !isConnectivityFailure(&net.DNSError{IsNotFound: true}) || !isConnectivityFailure(context.DeadlineExceeded) {
|
||||
t.Fatal("isConnectivityFailure mismatch")
|
||||
}
|
||||
if WrapErrorWithISPCheck(nil, "", "test") != nil {
|
||||
t.Fatal("nil wrap should stay nil")
|
||||
}
|
||||
|
||||
@@ -144,13 +144,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
errStr := strings.ToLower(err.Error())
|
||||
tlsRelated := strings.Contains(errStr, "tls") ||
|
||||
strings.Contains(errStr, "handshake") ||
|
||||
strings.Contains(errStr, "certificate") ||
|
||||
strings.Contains(errStr, "connection reset")
|
||||
|
||||
if tlsRelated {
|
||||
if isTLSHandshakeOrResetError(err) {
|
||||
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
||||
|
||||
reqCopy := req.Clone(req.Context())
|
||||
|
||||
+204
-29
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -76,6 +77,9 @@ var supportedAudioFormats = map[string]bool{
|
||||
".ape": true,
|
||||
".wv": true,
|
||||
".mpc": true,
|
||||
".wav": true,
|
||||
".aiff": true,
|
||||
".aif": true,
|
||||
".cue": true,
|
||||
}
|
||||
|
||||
@@ -89,6 +93,18 @@ type scannedCueFileInfo struct {
|
||||
audioPath string
|
||||
}
|
||||
|
||||
type libraryScanTask struct {
|
||||
index int
|
||||
info libraryAudioFileInfo
|
||||
}
|
||||
|
||||
type libraryScanTaskResult struct {
|
||||
index int
|
||||
path string
|
||||
results []LibraryScanResult
|
||||
err error
|
||||
}
|
||||
|
||||
func isLibraryStagingFile(path string) bool {
|
||||
name := strings.ToLower(filepath.Base(path))
|
||||
if strings.HasSuffix(name, ".partial") {
|
||||
@@ -147,6 +163,129 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
|
||||
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) {
|
||||
libraryCoverCacheMu.Lock()
|
||||
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 {
|
||||
filePath := fileInfo.path
|
||||
select {
|
||||
@@ -230,12 +373,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
default:
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ScannedFiles = i + 1
|
||||
libraryScanProgress.CurrentFile = filepath.Base(filePath)
|
||||
libraryScanProgress.ProgressPct = float64(i+1) / float64(totalFiles) * 100
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
if ext == ".cue" {
|
||||
@@ -257,26 +394,44 @@ func ScanLibraryFolder(folderPath string) (string, error) {
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning cue %s: %v\n", filePath, err)
|
||||
completedFiles++
|
||||
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
|
||||
continue
|
||||
}
|
||||
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))
|
||||
continue
|
||||
}
|
||||
|
||||
if cueReferencedAudioFiles[filePath] {
|
||||
completedFiles++
|
||||
updateLibraryScanProgress(completedFiles, totalFiles, filePath)
|
||||
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := scanAudioFileWithKnownModTime(filePath, scanTime, fileInfo.modTime)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning %s: %v\n", filePath, err)
|
||||
continue
|
||||
}
|
||||
audioTasks = append(audioTasks, libraryScanTask{index: i, info: fileInfo})
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -340,6 +495,10 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
|
||||
return scanOggFile(filePath, result, displayNameHint)
|
||||
case ".ape", ".wv", ".mpc":
|
||||
return scanAPEFile(filePath, result, displayNameHint)
|
||||
case ".wav":
|
||||
return scanWAVFile(filePath, result, displayNameHint)
|
||||
case ".aiff", ".aif", ".aifc":
|
||||
return scanAIFFFile(filePath, result, displayNameHint)
|
||||
default:
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
@@ -479,7 +638,7 @@ func libraryFormatForM4ACodec(codec string) string {
|
||||
|
||||
func isLosslessLibraryFormat(format string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "flac", "alac":
|
||||
case "flac", "alac", "wav", "aiff", "aif", "aifc":
|
||||
return true
|
||||
default:
|
||||
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 {
|
||||
select {
|
||||
case <-cancelCh:
|
||||
@@ -874,12 +1037,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
||||
default:
|
||||
}
|
||||
|
||||
libraryScanProgressMu.Lock()
|
||||
libraryScanProgress.ScannedFiles = skippedCount + i + 1
|
||||
libraryScanProgress.CurrentFile = filepath.Base(f.path)
|
||||
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
|
||||
libraryScanProgressMu.Unlock()
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(f.path))
|
||||
|
||||
if ext == ".cue" {
|
||||
@@ -901,24 +1058,42 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning cue %s: %v\n", f.path, err)
|
||||
completedFiles++
|
||||
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
|
||||
continue
|
||||
}
|
||||
results = append(results, cueResults...)
|
||||
resultsByIndex[i] = cueResults
|
||||
completedFiles++
|
||||
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
|
||||
continue
|
||||
}
|
||||
|
||||
if cueReferencedAudioFilesInc[f.path] {
|
||||
completedFiles++
|
||||
updateLibraryScanProgress(completedFiles, totalFiles, f.path)
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := scanAudioFileWithKnownModTime(f.path, scanTime, f.modTime)
|
||||
if err != nil {
|
||||
errorCount++
|
||||
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
|
||||
continue
|
||||
}
|
||||
audioTasks = append(audioTasks, libraryScanTask{index: i, info: f})
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
+555
-119
@@ -20,6 +20,12 @@ const (
|
||||
durationToleranceSec = 10.0
|
||||
)
|
||||
|
||||
const (
|
||||
lyricsProviderUnavailableCooldown = 10 * time.Minute
|
||||
lyricsProviderParallelism = 3
|
||||
lyricsProviderPriorityGrace = 5000 * time.Millisecond
|
||||
)
|
||||
|
||||
const (
|
||||
LyricsProviderLRCLIB = "lrclib"
|
||||
LyricsProviderNetease = "netease"
|
||||
@@ -31,6 +37,7 @@ const (
|
||||
LyricsProviderYouTube = "youtube"
|
||||
LyricsProviderKugou = "kugou"
|
||||
LyricsProviderGenius = "genius"
|
||||
LyricsProviderLyricsPlus = "lyricsplus"
|
||||
)
|
||||
|
||||
var DefaultLyricsProviders = []string{
|
||||
@@ -45,6 +52,33 @@ var (
|
||||
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) {
|
||||
normalized := strings.TrimSpace(version)
|
||||
|
||||
@@ -98,6 +132,7 @@ func SetLyricsProviderOrder(providers []string) {
|
||||
|
||||
if len(providers) == 0 {
|
||||
lyricsProviders = nil
|
||||
clearLyricsProviderHealth()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -112,6 +147,7 @@ func SetLyricsProviderOrder(providers []string) {
|
||||
LyricsProviderYouTube: true,
|
||||
LyricsProviderKugou: true,
|
||||
LyricsProviderGenius: true,
|
||||
LyricsProviderLyricsPlus: true,
|
||||
}
|
||||
|
||||
var valid []string
|
||||
@@ -123,9 +159,131 @@ func SetLyricsProviderOrder(providers []string) {
|
||||
}
|
||||
|
||||
lyricsProviders = valid
|
||||
clearLyricsProviderHealth()
|
||||
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 {
|
||||
lyricsProvidersMu.RLock()
|
||||
defer lyricsProvidersMu.RUnlock()
|
||||
@@ -151,6 +309,7 @@ func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||
{"id": LyricsProviderYouTube, "name": "YouTube", "has_proxy_dependency": true, "description": "YouTube lyrics"},
|
||||
{"id": LyricsProviderKugou, "name": "Kugou", "has_proxy_dependency": true, "description": "Kugou lyrics"},
|
||||
{"id": LyricsProviderGenius, "name": "Genius", "has_proxy_dependency": true, "description": "Genius lyrics"},
|
||||
{"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 {
|
||||
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)
|
||||
lyrics, err := provider.FetchLyrics(trackName, artistName, "", durationSec)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
GoLog("[Lyrics] Got lyrics from extension: %s\n", provider.extension.ID)
|
||||
markLyricsProviderAvailable(providerName)
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
if err != nil {
|
||||
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()
|
||||
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)
|
||||
|
||||
for _, providerName := range providerOrder {
|
||||
GoLog("[Lyrics] Trying provider: %s\n", providerName)
|
||||
lyrics, err := fetchBuiltInLyricsProviders(providerOrder, request, c.fetchBuiltInLyricsProvider)
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
var lyrics *LyricsResponse
|
||||
var err error
|
||||
return nil, fmt.Errorf("lyrics not found from any source")
|
||||
}
|
||||
|
||||
switch providerName {
|
||||
case LyricsProviderLRCLIB:
|
||||
lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec)
|
||||
func fetchBuiltInLyricsProviders(
|
||||
providerOrder []string,
|
||||
request lyricsProviderSearchRequest,
|
||||
fetchProvider func(string, lyricsProviderSearchRequest) (*LyricsResponse, error, bool),
|
||||
) (*LyricsResponse, error) {
|
||||
type providerCandidate struct {
|
||||
index int
|
||||
name string
|
||||
}
|
||||
|
||||
case LyricsProviderNetease:
|
||||
neteaseClient := NewNeteaseClient()
|
||||
lyrics, err = neteaseClient.FetchLyrics(
|
||||
trackName,
|
||||
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,
|
||||
)
|
||||
}
|
||||
candidates := make([]providerCandidate, 0, len(providerOrder))
|
||||
results := make(chan lyricsProviderSearchResult, len(providerOrder))
|
||||
sem := make(chan struct{}, lyricsProviderParallelism)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
case LyricsProviderMusixmatch:
|
||||
musixmatchClient := NewMusixmatchClient()
|
||||
lyrics, err = musixmatchClient.FetchLyrics(
|
||||
trackName,
|
||||
primaryArtist,
|
||||
durationSec,
|
||||
fetchOptions.MusixmatchLanguage,
|
||||
)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = musixmatchClient.FetchLyrics(
|
||||
trackName,
|
||||
artistName,
|
||||
durationSec,
|
||||
fetchOptions.MusixmatchLanguage,
|
||||
)
|
||||
}
|
||||
for index, providerName := range providerOrder {
|
||||
if skip, remaining, reason := shouldSkipLyricsProvider(providerName); skip {
|
||||
GoLog("[Lyrics] Skipping unavailable provider %s for %s: %s\n", providerName, remaining.Round(time.Second), reason)
|
||||
continue
|
||||
}
|
||||
|
||||
case LyricsProviderAppleMusic:
|
||||
appleClient := NewAppleMusicClient()
|
||||
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:
|
||||
knownProvider := isKnownBuiltInLyricsProvider(providerName)
|
||||
if !knownProvider {
|
||||
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
|
||||
continue
|
||||
}
|
||||
|
||||
if err == nil && isValidResult(lyrics) {
|
||||
GoLog("[Lyrics] Got lyrics from: %s\n", providerName)
|
||||
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||
return lyrics, nil
|
||||
candidate := providerCandidate{index: index, name: providerName}
|
||||
candidates = append(candidates, candidate)
|
||||
wg.Add(1)
|
||||
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 {
|
||||
GoLog("[Lyrics] Provider %s failed: %v\n", providerName, err)
|
||||
select {
|
||||
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")
|
||||
}
|
||||
|
||||
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) {
|
||||
var lyrics *LyricsResponse
|
||||
var err error
|
||||
@@ -640,6 +1000,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
||||
lyrics.Source = "LRCLIB"
|
||||
return lyrics, nil
|
||||
}
|
||||
if isLyricsProviderUnavailableError(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if primaryArtist != artistName {
|
||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||
@@ -647,6 +1010,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
||||
lyrics.Source = "LRCLIB"
|
||||
return lyrics, nil
|
||||
}
|
||||
if isLyricsProviderUnavailableError(err) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if simplifiedTrack != trackName {
|
||||
@@ -655,6 +1021,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
||||
lyrics.Source = "LRCLIB (simplified)"
|
||||
return lyrics, nil
|
||||
}
|
||||
if isLyricsProviderUnavailableError(err) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
query := primaryArtist + " " + trackName
|
||||
@@ -663,6 +1032,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
||||
lyrics.Source = "LRCLIB Search"
|
||||
return lyrics, nil
|
||||
}
|
||||
if isLyricsProviderUnavailableError(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if simplifiedTrack != trackName {
|
||||
query = primaryArtist + " " + simplifiedTrack
|
||||
@@ -671,6 +1043,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie
|
||||
lyrics.Source = "LRCLIB Search (simplified)"
|
||||
return lyrics, nil
|
||||
}
|
||||
if isLyricsProviderUnavailableError(err) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -843,6 +1230,41 @@ func msToLRCTimestampInline(ms int64) string {
|
||||
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 {
|
||||
if lyrics == nil || len(lyrics.Lines) == 0 {
|
||||
return ""
|
||||
@@ -852,7 +1274,21 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
|
||||
|
||||
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
|
||||
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")
|
||||
|
||||
if lyrics.SyncType == "LINE_SYNCED" {
|
||||
|
||||
@@ -2,6 +2,7 @@ package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
@@ -13,6 +14,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var errAppleMusicUnauthorized = errors.New("apple music catalog search unauthorized")
|
||||
|
||||
type AppleMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
@@ -188,7 +191,7 @@ func (c *AppleMusicClient) getAppleMusicToken() (string, error) {
|
||||
return "", fmt.Errorf("failed to read apple music script: %w", err)
|
||||
}
|
||||
|
||||
token := regexp.MustCompile(`eyJh[^"' <]+`).FindString(string(jsBody))
|
||||
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")
|
||||
}
|
||||
@@ -235,7 +238,7 @@ func (c *AppleMusicClient) searchSongWithToken(token, query string) ([]appleMusi
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return nil, fmt.Errorf("apple music catalog search unauthorized")
|
||||
return nil, errAppleMusicUnauthorized
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("apple music catalog search returned HTTP %d", resp.StatusCode)
|
||||
@@ -281,7 +284,7 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec
|
||||
}
|
||||
|
||||
searchResp, err := c.searchSongWithToken(token, strings.TrimSpace(query))
|
||||
if err != nil && strings.Contains(strings.ToLower(err.Error()), "unauthorized") {
|
||||
if errors.Is(err, errAppleMusicUnauthorized) {
|
||||
clearAppleMusicToken()
|
||||
token, tokenErr := c.getAppleMusicToken()
|
||||
if tokenErr != nil {
|
||||
|
||||
@@ -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"`
|
||||
SongCount int `json:"songCount"`
|
||||
} `json:"result"`
|
||||
Code int `json:"code"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
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.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)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("genius search failed: %w", err)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
@@ -54,6 +55,15 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
|
||||
if msg, ok := detectLyricsErrorPayload(`{"success":false,"message":"nope"}`); !ok || msg != "nope" {
|
||||
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]" {
|
||||
t.Fatal("unexpected LRC timestamp conversion")
|
||||
}
|
||||
@@ -130,9 +140,120 @@ 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) {
|
||||
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}]}]}`
|
||||
apple := &AppleMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
@@ -140,7 +261,7 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
||||
case req.URL.Host == "beta.music.apple.com" && (req.URL.Path == "" || req.URL.Path == "/"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`<script src="/assets/index~test.js"></script>`)), 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="eyJhbGci.test";`)), Request: req}, nil
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`const token="eyJ0eXAiOiJKV1Q.eyJpc3MiOiJ0ZXN0.c2ln";`)), 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"):
|
||||
@@ -236,6 +357,12 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
||||
if _, err := netease.SearchSong("", ""); err == nil {
|
||||
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) {
|
||||
if req.Method != http.MethodPost {
|
||||
@@ -311,6 +438,9 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
||||
genius := &GeniusLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
stdimage "image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"math"
|
||||
@@ -71,11 +71,83 @@ func detectCoverMIME(coverPath string, coverData []byte) string {
|
||||
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) {
|
||||
if len(coverData) == 0 {
|
||||
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)
|
||||
picture := &flacpicture.MetadataBlockPicture{
|
||||
PictureType: flacpicture.PictureTypeFrontCover,
|
||||
@@ -175,10 +247,11 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
||||
|
||||
picBlock, err := buildPictureBlock(coverPath, coverData)
|
||||
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 {
|
||||
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)
|
||||
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)
|
||||
@@ -906,6 +980,32 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1097,9 +1197,7 @@ func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
|
||||
udtaBodyStart := udta.offset + udta.headerSize
|
||||
udtaBodySize := udta.size - udta.headerSize
|
||||
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
|
||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
||||
metaBodySize := meta.size - meta.headerSize - 4
|
||||
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
|
||||
if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 {
|
||||
return ilst, nil
|
||||
}
|
||||
}
|
||||
@@ -1107,9 +1205,7 @@ func findM4AIlstAtom(f *os.File, fileSize int64) (atomHeader, error) {
|
||||
|
||||
// Path 2: moov > meta > ilst (no udta wrapper)
|
||||
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
|
||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
||||
metaBodySize := meta.size - meta.headerSize - 4
|
||||
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
|
||||
if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 {
|
||||
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)")
|
||||
}
|
||||
|
||||
// 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) {
|
||||
payloadStart := dataAtom.offset + 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
|
||||
udtaBodySize := udta.size - udta.headerSize
|
||||
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
|
||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
||||
metaBodySize := meta.size - meta.headerSize - 4
|
||||
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
|
||||
if ilst, ok3 := findIlstInMeta(f, meta, fileSize); ok3 {
|
||||
udtaCopy := udta
|
||||
return m4aMetadataPath{
|
||||
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 {
|
||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
||||
metaBodySize := meta.size - meta.headerSize - 4
|
||||
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
|
||||
if ilst, ok2 := findIlstInMeta(f, meta, fileSize); ok2 {
|
||||
return m4aMetadataPath{
|
||||
moov: moov,
|
||||
meta: meta,
|
||||
@@ -1406,6 +1518,51 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1419,6 +1576,13 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
||||
|
||||
path, err := findM4AMetadataPath(f, info.Size())
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1430,13 +1594,6 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
||||
bodyStart := path.ilst.offset + path.ilst.headerSize
|
||||
bodyEnd := path.ilst.offset + 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; {
|
||||
header, readErr := readAtomHeaderAt(f, pos, info.Size())
|
||||
@@ -1454,7 +1611,7 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
||||
if header.typ == "----" {
|
||||
name, _, freeformErr := readM4AFreeformValue(f, header, info.Size())
|
||||
if freeformErr == nil {
|
||||
if _, ok := targets[strings.ToUpper(strings.TrimSpace(name))]; ok {
|
||||
if _, ok := remove[strings.ToUpper(strings.TrimSpace(name))]; ok {
|
||||
keep = false
|
||||
}
|
||||
}
|
||||
@@ -1466,23 +1623,11 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
||||
pos += header.size
|
||||
}
|
||||
|
||||
order := []string{
|
||||
"replaygain_track_gain",
|
||||
"replaygain_track_peak",
|
||||
"replaygain_album_gain",
|
||||
"replaygain_album_peak",
|
||||
"iTunNORM",
|
||||
}
|
||||
for _, key := range order {
|
||||
value := strings.TrimSpace(replayGainFields[key])
|
||||
if value == "" {
|
||||
for _, tag := range tags {
|
||||
if strings.TrimSpace(tag.value) == "" {
|
||||
continue
|
||||
}
|
||||
name := key
|
||||
if key != "iTunNORM" {
|
||||
name = strings.ToLower(key)
|
||||
}
|
||||
newBody = append(newBody, buildM4AFreeformAtom(name, value)...)
|
||||
newBody = append(newBody, buildM4AFreeformAtom(tag.name, tag.value)...)
|
||||
}
|
||||
|
||||
newIlst := buildM4AAtom("ilst", newBody)
|
||||
@@ -1509,6 +1654,32 @@ func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
||||
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) {
|
||||
ext := filepath.Ext(filePath)
|
||||
base := strings.TrimSuffix(filePath, ext)
|
||||
|
||||
@@ -35,7 +35,7 @@ func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) {
|
||||
if err == 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 nil, err
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import Gobackend // Import Go framework
|
||||
import Gobackend
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@@ -17,9 +17,16 @@ import Gobackend // Import Go framework
|
||||
private var libraryScanProgressTimer: DispatchSourceTimer?
|
||||
private var libraryScanProgressEventSink: FlutterEventSink?
|
||||
private var lastLibraryScanProgressPayload: String?
|
||||
private var backendChannel: FlutterMethodChannel?
|
||||
private var pendingSessionGrantEvents: [[String: Any]] = []
|
||||
|
||||
/// Currently accessed security-scoped URL for library folder
|
||||
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(
|
||||
_ application: UIApplication,
|
||||
@@ -34,6 +41,14 @@ import Gobackend // Import Go framework
|
||||
name: CHANNEL,
|
||||
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(
|
||||
name: DOWNLOAD_PROGRESS_STREAM_CHANNEL,
|
||||
binaryMessenger: controller.binaryMessenger
|
||||
@@ -78,20 +93,25 @@ import Gobackend // Import Go framework
|
||||
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
|
||||
private func handleExtensionOAuthRedirect(url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased(), scheme == "spotiflac" else { return false }
|
||||
let host = (url.host ?? "").lowercased()
|
||||
let path = url.path.lowercased()
|
||||
let isSessionGrant = host == "session-grant"
|
||||
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 let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return false
|
||||
}
|
||||
let q = components.queryItems ?? []
|
||||
let code =
|
||||
q.first { $0.name == (isSessionGrant ? "grant" : "code") }?.value?.trimmingCharacters(
|
||||
in: .whitespacesAndNewlines) ??
|
||||
q.first { $0.name == "code" }?.value?.trimmingCharacters(
|
||||
in: .whitespacesAndNewlines) ?? ""
|
||||
let state =
|
||||
@@ -104,16 +124,37 @@ import Gobackend // Import Go framework
|
||||
}
|
||||
streamQueue.async {
|
||||
var err: NSError?
|
||||
GobackendSetExtensionAuthCodeByID(state, code)
|
||||
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
|
||||
if isSessionGrant {
|
||||
GobackendSetExtensionSessionGrantByID(state, code)
|
||||
_ = GobackendInvokeExtensionActionJSON(state, "completeGrant", &err)
|
||||
} else {
|
||||
GobackendSetExtensionAuthCodeByID(state, code)
|
||||
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
|
||||
}
|
||||
if let err = err {
|
||||
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
|
||||
}
|
||||
|
||||
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(
|
||||
_ app: UIApplication,
|
||||
open url: URL,
|
||||
@@ -233,6 +274,20 @@ import Gobackend // Import Go framework
|
||||
}
|
||||
|
||||
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 {
|
||||
do {
|
||||
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? {
|
||||
var error: NSError?
|
||||
@@ -310,6 +393,12 @@ import Gobackend // Import Go framework
|
||||
let insecureTLS = args["insecure_tls"] as? Bool ?? false
|
||||
GobackendSetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
|
||||
return nil
|
||||
|
||||
case "setAllowPrivateNetwork":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let allowed = args["allowed"] as? Bool ?? false
|
||||
GobackendSetAllowPrivateNetwork(allowed)
|
||||
return nil
|
||||
|
||||
case "checkDuplicate":
|
||||
let args = call.arguments as! [String: Any]
|
||||
@@ -543,7 +632,6 @@ import Gobackend // Import Go framework
|
||||
GobackendClearTrackCache()
|
||||
return nil
|
||||
|
||||
// Log methods
|
||||
case "getLogs":
|
||||
let response = GobackendGetLogs()
|
||||
return response
|
||||
@@ -568,7 +656,6 @@ import Gobackend // Import Go framework
|
||||
GobackendSetLoggingEnabled(enabled)
|
||||
return nil
|
||||
|
||||
// Extension System methods
|
||||
case "initExtensionSystem":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionsDir = args["extensions_dir"] as! String
|
||||
@@ -733,7 +820,6 @@ import Gobackend // Import Go framework
|
||||
GobackendCleanupExtensions()
|
||||
return nil
|
||||
|
||||
// Extension Auth API
|
||||
case "getExtensionPendingAuth":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
@@ -774,7 +860,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Extension FFmpeg API
|
||||
case "getPendingFFmpegCommand":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let commandId = args["command_id"] as! String
|
||||
@@ -796,7 +881,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Extension Custom Search API
|
||||
case "customSearchWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
@@ -818,7 +902,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Extension URL Handler API
|
||||
case "handleURLWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let url = args["url"] as! String
|
||||
@@ -837,7 +920,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Extension Post-Processing API
|
||||
case "runPostProcessing":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let filePath = args["file_path"] as! String
|
||||
@@ -859,7 +941,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Extension Store
|
||||
case "initExtensionStore":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let cacheDir = args["cache_dir"] as! String
|
||||
@@ -917,7 +998,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
// Extension Home Feed API
|
||||
case "getExtensionHomeFeed":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
@@ -933,7 +1013,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Local Library Scanning
|
||||
case "setLibraryCoverCacheDir":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let cacheDir = args["cache_dir"] as! String
|
||||
@@ -970,7 +1049,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// iOS Security-Scoped Bookmark for Local Library
|
||||
case "resolveIosBookmark":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let bookmarkBase64 = args["bookmark"] as! String
|
||||
@@ -990,7 +1068,6 @@ import Gobackend // Import Go framework
|
||||
let path = args["path"] as! String
|
||||
return try createIosBookmarkFromPath(path)
|
||||
|
||||
// Lyrics Provider Settings
|
||||
case "setLyricsProviders":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let providersJson = args["providers_json"] as? String ?? "[]"
|
||||
@@ -1020,7 +1097,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// CUE Sheet Parsing
|
||||
case "parseCueSheet":
|
||||
let args = call.arguments as! [String: Any]
|
||||
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/tutorial_screen.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/l10n/app_localizations.dart';
|
||||
|
||||
@@ -28,6 +29,7 @@ final _routerProvider = Provider<GoRouter>((ref) {
|
||||
}
|
||||
|
||||
return GoRouter(
|
||||
navigatorKey: AppNavigationService.rootNavigatorKey,
|
||||
initialLocation: initialLocation,
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (context, state) => const MainShell()),
|
||||
@@ -114,6 +116,15 @@ class SpotiFLACApp extends ConsumerWidget {
|
||||
scrollBehavior: scrollBehavior,
|
||||
themeAnimationDuration: const Duration(milliseconds: 300),
|
||||
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,
|
||||
locale: locale,
|
||||
localeResolutionCallback: (deviceLocale, supportedLocales) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class AppInfo {
|
||||
static const String version = '4.5.6';
|
||||
static const String buildNumber = '133';
|
||||
static const String version = '4.7.1';
|
||||
static const String buildNumber = '137';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
||||
|
||||
+857
-56
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+940
-395
File diff suppressed because it is too large
Load Diff
@@ -142,7 +142,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
'Choose the default search provider to switch back from an extension';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallback => 'Auto Fallback';
|
||||
@@ -155,10 +155,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
||||
String get optionsUseExtensionProvidersOn =>
|
||||
'Extension providers are enabled';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
|
||||
String get optionsUseExtensionProvidersOff =>
|
||||
'Extension providers are required';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyrics => 'Embed Lyrics';
|
||||
@@ -185,6 +187,43 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'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
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -206,21 +245,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'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
|
||||
String get optionsExtensionStore => 'Extension Repo';
|
||||
|
||||
@@ -385,11 +409,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
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
|
||||
String get aboutSjdonadoDesc =>
|
||||
@@ -579,6 +603,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get previewPlay => 'Play preview';
|
||||
|
||||
@override
|
||||
String get previewStop => 'Stop preview';
|
||||
|
||||
@override
|
||||
String get previewUnavailable => 'Preview unavailable';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Discard';
|
||||
|
||||
@@ -953,7 +986,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
String get providerBuiltIn => 'Legacy';
|
||||
|
||||
@override
|
||||
String get providerExtension => 'Extension';
|
||||
@@ -1346,10 +1379,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
String get extensionDefaultProvider => 'Default Search';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
||||
String get extensionDefaultProviderSubtitle =>
|
||||
'Use the default metadata search';
|
||||
|
||||
@override
|
||||
String get extensionAuthor => 'Author';
|
||||
@@ -1523,7 +1557,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
@@ -1567,6 +1601,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||
|
||||
@override
|
||||
String get albumFolderStructureDescription =>
|
||||
'Choose how album folders are structured';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
|
||||
@@ -1910,7 +1948,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
String libraryTracksUnit(int count) {
|
||||
@@ -2093,7 +2131,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
'Get FLAC quality audio from installed download extensions';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2791,7 +2829,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
@@ -2833,6 +2871,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLyricsPlusDesc =>
|
||||
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
|
||||
@@ -2856,6 +2898,164 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
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
|
||||
String get tooltipLoveAll => 'Love All';
|
||||
|
||||
@@ -2985,13 +3185,24 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'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
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select Tidal or Qobuz to enable this option';
|
||||
'Select a provider with quality options to enable this option';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz to choose audio quality';
|
||||
'Select a provider with quality options to choose audio quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
|
||||
@@ -4245,4 +4456,318 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
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';
|
||||
}
|
||||
|
||||
+1744
-452
File diff suppressed because it is too large
Load Diff
+1735
-1094
File diff suppressed because it is too large
Load Diff
@@ -126,7 +126,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsPrimaryProviderSubtitle =>
|
||||
'Service used when searching by track name.';
|
||||
'Service used for searching by track or album name';
|
||||
|
||||
@override
|
||||
String optionsUsingExtension(String extensionName) {
|
||||
@@ -142,7 +142,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
'Choose the default search provider to switch back from an extension';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallback => 'Auto Fallback';
|
||||
@@ -155,17 +155,19 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
||||
String get optionsUseExtensionProvidersOn =>
|
||||
'Extension providers are enabled';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
|
||||
String get optionsUseExtensionProvidersOff =>
|
||||
'Extension providers are required';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyricsSubtitle =>
|
||||
'Embed synced lyrics into FLAC files';
|
||||
'Save synced lyrics alongside your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get optionsMaxQualityCover => 'Max Quality Cover';
|
||||
@@ -185,6 +187,43 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'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
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -206,21 +245,6 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'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
|
||||
String get optionsExtensionStore => 'Extension Repo';
|
||||
|
||||
@@ -385,11 +409,11 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
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
|
||||
String get aboutSjdonadoDesc =>
|
||||
@@ -579,6 +603,15 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get previewPlay => 'Play preview';
|
||||
|
||||
@override
|
||||
String get previewStop => 'Stop preview';
|
||||
|
||||
@override
|
||||
String get previewUnavailable => 'Preview unavailable';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Discard';
|
||||
|
||||
@@ -953,7 +986,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
String get providerBuiltIn => 'Legacy';
|
||||
|
||||
@override
|
||||
String get providerExtension => 'Extension';
|
||||
@@ -1121,10 +1154,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get settingsAppearanceSubtitle => 'Theme, colors, display';
|
||||
|
||||
@override
|
||||
String get settingsDownloadSubtitle => 'Service, quality, filename format';
|
||||
String get settingsDownloadSubtitle => 'Service, quality, fallback';
|
||||
|
||||
@override
|
||||
String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
|
||||
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
|
||||
|
||||
@override
|
||||
String get settingsExtensionsSubtitle => 'Manage download providers';
|
||||
@@ -1346,10 +1379,11 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
String get extensionDefaultProvider => 'Default Search';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
||||
String get extensionDefaultProviderSubtitle =>
|
||||
'Use the default metadata search';
|
||||
|
||||
@override
|
||||
String get extensionAuthor => 'Author';
|
||||
@@ -1523,7 +1557,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
@@ -1567,6 +1601,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||
|
||||
@override
|
||||
String get albumFolderStructureDescription =>
|
||||
'Choose how album folders are structured';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
|
||||
@@ -2093,7 +2131,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
'Get FLAC quality audio from installed download extensions';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2423,7 +2461,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
@@ -2776,14 +2814,14 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
'Folder named after Album Artist tag';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
'Folder named after Track Artist tag';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
@@ -2791,7 +2829,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
@@ -2833,6 +2871,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLyricsPlusDesc =>
|
||||
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
|
||||
@@ -2851,10 +2893,168 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Donate';
|
||||
String get settingsDonate => 'Support Development';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||
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
|
||||
String get tooltipLoveAll => 'Love All';
|
||||
@@ -2914,20 +3114,20 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choose storage mode for downloaded files.';
|
||||
'Choose where to save your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||
String get storageModeAppFolder => 'App Folder (Recommended)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||
String get storageModeAppFolderSubtitle =>
|
||||
'Saves to Music/SpotiFLAC by default';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'SAF folder';
|
||||
String get storageModeSaf => 'Custom Folder (SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Pick folder via Android Storage Access Framework';
|
||||
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
|
||||
|
||||
@override
|
||||
String downloadFilenameDescription(
|
||||
@@ -2939,62 +3139,73 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
Object track,
|
||||
Object year,
|
||||
) {
|
||||
return 'Customize how your files are named.';
|
||||
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||
String get downloadSeparateSinglesEnabled =>
|
||||
'Singles and EPs saved in a separate folder';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||
String get downloadSeparateSinglesDisabled =>
|
||||
'Singles and albums saved in the same folder';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
'A subfolder is created for each playlist';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
'All tracks saved directly to download folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
'Handled by folder organization setting';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||
'Using legacy TLS settings for older networks';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Off: strict HTTPS certificate validation (recommended)';
|
||||
'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
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
'Select a provider with quality options to enable this option';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
'Select a provider with quality options to choose audio quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Disabled while Embed Metadata is turned off';
|
||||
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation =>
|
||||
@@ -3002,11 +3213,11 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Append translated lyrics when available';
|
||||
'Chinese translation lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Use original lyrics only';
|
||||
'Original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
@@ -3014,21 +3225,21 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Append romanized lyrics when available';
|
||||
'Romanization lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Enable v1/v2 speaker and [bg:] tags';
|
||||
'Speaker labels included for duets and group tracks';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
'Standard lyrics without speaker labels';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
@@ -3045,46 +3256,45 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Filter contributing artists in Album Artist';
|
||||
String get downloadFilterContributing => 'Filter Contributing Artists';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Album Artist metadata uses primary artist only';
|
||||
'Contributing artists removed from Album Artist folder name';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Keep full Album Artist metadata value';
|
||||
'Full Album Artist string used';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||
String get downloadProvidersNoneEnabled => 'No providers enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Pause downloads on mobile data';
|
||||
'Downloads pause when on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Used as userCountry for SongLink API lookup.';
|
||||
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||
@@ -3470,7 +3680,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String notifTracksDownloadedSuccess(int count) {
|
||||
return '$count tracks downloaded successfully';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks downloaded successfully',
|
||||
one: '1 track downloaded successfully',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -4240,4 +4456,318 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
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';
|
||||
}
|
||||
|
||||
+634
-111
File diff suppressed because it is too large
Load Diff
@@ -126,7 +126,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsPrimaryProviderSubtitle =>
|
||||
'Service used when searching by track name.';
|
||||
'Service used for searching by track or album name';
|
||||
|
||||
@override
|
||||
String optionsUsingExtension(String extensionName) {
|
||||
@@ -142,7 +142,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
'Choose the default search provider to switch back from an extension';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallback => 'Auto Fallback';
|
||||
@@ -155,16 +155,19 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get optionsUseExtensionProviders => '拡張のプロバイダーを使用する';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOn => '最初に拡張で試みます';
|
||||
String get optionsUseExtensionProvidersOn =>
|
||||
'Extension providers are enabled';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOff => '内蔵のプロバイダーのみを使用する';
|
||||
String get optionsUseExtensionProvidersOff =>
|
||||
'Extension providers are required';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyrics => '歌詞を埋め込む';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyricsSubtitle => '同期する歌詞を FLAC ファイルに埋め込む';
|
||||
String get optionsEmbedLyricsSubtitle =>
|
||||
'Save synced lyrics alongside your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get optionsMaxQualityCover => '最大品質のカバー';
|
||||
@@ -183,6 +186,43 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'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
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -204,21 +244,6 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => '同時ダウンロード';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentSequential => 'Sequential (1 at a time)';
|
||||
|
||||
@override
|
||||
String optionsConcurrentParallel(int count) {
|
||||
return '$count 件の分割ダウンロード';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsConcurrentWarning =>
|
||||
'Parallel downloads may trigger rate limiting';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Extension Repo';
|
||||
|
||||
@@ -381,11 +406,11 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
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
|
||||
String get aboutSjdonadoDesc =>
|
||||
@@ -575,6 +600,15 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get previewPlay => 'Play preview';
|
||||
|
||||
@override
|
||||
String get previewStop => 'Stop preview';
|
||||
|
||||
@override
|
||||
String get previewUnavailable => 'Preview unavailable';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => '破棄';
|
||||
|
||||
@@ -947,7 +981,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => '内蔵';
|
||||
String get providerBuiltIn => 'Legacy';
|
||||
|
||||
@override
|
||||
String get providerExtension => '拡張';
|
||||
@@ -1115,10 +1149,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get settingsAppearanceSubtitle => 'テーマ、カラー、画面';
|
||||
|
||||
@override
|
||||
String get settingsDownloadSubtitle => 'サービス、品質、ファイル名、形式';
|
||||
String get settingsDownloadSubtitle => 'Service, quality, fallback';
|
||||
|
||||
@override
|
||||
String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
|
||||
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
|
||||
|
||||
@override
|
||||
String get settingsExtensionsSubtitle => 'ダウンロードプロバイダーを管理';
|
||||
@@ -1340,10 +1374,11 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
String get extensionDefaultProvider => 'Default Search';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => '内蔵の検索を使用する';
|
||||
String get extensionDefaultProviderSubtitle =>
|
||||
'Use the default metadata search';
|
||||
|
||||
@override
|
||||
String get extensionAuthor => '作者';
|
||||
@@ -1513,7 +1548,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
@@ -1556,6 +1591,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'アルバムフォルダの構造';
|
||||
|
||||
@override
|
||||
String get albumFolderStructureDescription => 'アルバムフォルダの構成を選択';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
|
||||
@@ -2080,7 +2118,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
'Get FLAC quality audio from installed download extensions';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2410,7 +2448,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'オーディオを変換';
|
||||
@@ -2763,14 +2801,14 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
'Folder named after Album Artist tag';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
'Folder named after Track Artist tag';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
@@ -2778,7 +2816,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
@@ -2820,6 +2858,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLyricsPlusDesc =>
|
||||
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
|
||||
@@ -2838,10 +2880,168 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Donate';
|
||||
String get settingsDonate => 'Support Development';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||
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
|
||||
String get tooltipLoveAll => 'Love All';
|
||||
@@ -2901,20 +3101,20 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choose storage mode for downloaded files.';
|
||||
'Choose where to save your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||
String get storageModeAppFolder => 'App Folder (Recommended)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||
String get storageModeAppFolderSubtitle =>
|
||||
'Saves to Music/SpotiFLAC by default';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'SAF folder';
|
||||
String get storageModeSaf => 'Custom Folder (SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Pick folder via Android Storage Access Framework';
|
||||
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
|
||||
|
||||
@override
|
||||
String downloadFilenameDescription(
|
||||
@@ -2926,62 +3126,73 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
Object track,
|
||||
Object year,
|
||||
) {
|
||||
return 'Customize how your files are named.';
|
||||
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||
String get downloadSeparateSinglesEnabled =>
|
||||
'Singles and EPs saved in a separate folder';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||
String get downloadSeparateSinglesDisabled =>
|
||||
'Singles and albums saved in the same folder';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
'A subfolder is created for each playlist';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
'All tracks saved directly to download folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
'Handled by folder organization setting';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||
'Using legacy TLS settings for older networks';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Off: strict HTTPS certificate validation (recommended)';
|
||||
'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
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
'Select a provider with quality options to enable this option';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
'Select a provider with quality options to choose audio quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Disabled while Embed Metadata is turned off';
|
||||
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation =>
|
||||
@@ -2989,11 +3200,11 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Append translated lyrics when available';
|
||||
'Chinese translation lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Use original lyrics only';
|
||||
'Original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
@@ -3001,21 +3212,21 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Append romanized lyrics when available';
|
||||
'Romanization lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Enable v1/v2 speaker and [bg:] tags';
|
||||
'Speaker labels included for duets and group tracks';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
'Standard lyrics without speaker labels';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
@@ -3032,46 +3243,45 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Filter contributing artists in Album Artist';
|
||||
String get downloadFilterContributing => 'Filter Contributing Artists';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Album Artist metadata uses primary artist only';
|
||||
'Contributing artists removed from Album Artist folder name';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Keep full Album Artist metadata value';
|
||||
'Full Album Artist string used';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||
String get downloadProvidersNoneEnabled => 'No providers enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Pause downloads on mobile data';
|
||||
'Downloads pause when on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Used as userCountry for SongLink API lookup.';
|
||||
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||
@@ -3457,7 +3667,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String notifTracksDownloadedSuccess(int count) {
|
||||
return '$count tracks downloaded successfully';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks downloaded successfully',
|
||||
one: '1 track downloaded successfully',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -4227,4 +4443,318 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get homeTitle => 'Home';
|
||||
|
||||
@override
|
||||
String get homeSubtitle => 'Paste a supported URL or search by name';
|
||||
String get homeSubtitle => '지원되는 URL을 붙여 넣거나, 이름을 검색';
|
||||
|
||||
@override
|
||||
String get homeEmptyTitle => 'No search providers yet';
|
||||
@@ -97,10 +97,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get appearanceThemeSystem => 'System';
|
||||
|
||||
@override
|
||||
String get appearanceThemeLight => 'Light';
|
||||
String get appearanceThemeLight => '밝은';
|
||||
|
||||
@override
|
||||
String get appearanceThemeDark => 'Dark';
|
||||
String get appearanceThemeDark => '다크';
|
||||
|
||||
@override
|
||||
String get appearanceDynamicColor => 'Dynamic Color';
|
||||
@@ -124,7 +124,8 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get optionsPrimaryProvider => '기본 제공자';
|
||||
|
||||
@override
|
||||
String get optionsPrimaryProviderSubtitle => '음반 이름으로 검색할 때 사용되는 서비스';
|
||||
String get optionsPrimaryProviderSubtitle =>
|
||||
'Service used for searching by track or album name';
|
||||
|
||||
@override
|
||||
String optionsUsingExtension(String extensionName) {
|
||||
@@ -139,7 +140,8 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack => 'Deezer 또는 Spotify를 탭하여 확장 기능에서 다시 전환하세요.';
|
||||
String get optionsSwitchBack =>
|
||||
'Choose the default search provider to switch back from an extension';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallback => '자동 재시도';
|
||||
@@ -151,16 +153,19 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get optionsUseExtensionProviders => '확장 기능 사용';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOn => '확장 기능을 우선적으로 사용합니다';
|
||||
String get optionsUseExtensionProvidersOn =>
|
||||
'Extension providers are enabled';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOff => '기본으로 제공되는 기능만 사용';
|
||||
String get optionsUseExtensionProvidersOff =>
|
||||
'Extension providers are required';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyrics => '가사 삽입';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyricsSubtitle => 'FLAC 파일에 동기화된 가사를 삽입합니다';
|
||||
String get optionsEmbedLyricsSubtitle =>
|
||||
'Save synced lyrics alongside your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get optionsMaxQualityCover => '고품질 커버 이미지';
|
||||
@@ -179,6 +184,43 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'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
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -200,20 +242,6 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => '동시 다운로드';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentSequential => '순차 다운로드 (한 번에 하나)';
|
||||
|
||||
@override
|
||||
String optionsConcurrentParallel(int count) {
|
||||
return '$count개 동시 다운로드';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsConcurrentWarning => '동시에 다수의 음반을 다운로드하면 속도 제한이 발생할 수 있습니다';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Extension Repo';
|
||||
|
||||
@@ -374,10 +402,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutBinimumDesc =>
|
||||
'QQDL 및 HiFi API 개발자입니다. 이 API가 없었다면 Tidal 다운로드는 불가능했을 것입니다!';
|
||||
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
|
||||
|
||||
@override
|
||||
String get aboutSachinsenalDesc => '최초의 하이파이 프로젝트 창시자. 타이달 연동의 기반을 마련한 사람!';
|
||||
String get aboutSachinsenalDesc =>
|
||||
'The original HiFi project creator. A foundation for lossless-source integration.';
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
@@ -564,6 +593,15 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get previewPlay => 'Play preview';
|
||||
|
||||
@override
|
||||
String get previewStop => 'Stop preview';
|
||||
|
||||
@override
|
||||
String get previewUnavailable => 'Preview unavailable';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => '취소';
|
||||
|
||||
@@ -829,7 +867,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get tooltipPlay => '재생';
|
||||
|
||||
@override
|
||||
String get filenameFormat => '';
|
||||
String get filenameFormat => 'Filename Format';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => '고급 태그 표시';
|
||||
@@ -935,7 +973,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
String get providerBuiltIn => 'Legacy';
|
||||
|
||||
@override
|
||||
String get providerExtension => 'Extension';
|
||||
@@ -1101,10 +1139,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get settingsAppearanceSubtitle => 'Theme, colors, display';
|
||||
|
||||
@override
|
||||
String get settingsDownloadSubtitle => 'Service, quality, filename format';
|
||||
String get settingsDownloadSubtitle => 'Service, quality, fallback';
|
||||
|
||||
@override
|
||||
String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
|
||||
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
|
||||
|
||||
@override
|
||||
String get settingsExtensionsSubtitle => 'Manage download providers';
|
||||
@@ -1326,10 +1364,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
String get extensionDefaultProvider => 'Default Search';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
||||
String get extensionDefaultProviderSubtitle =>
|
||||
'Use the default metadata search';
|
||||
|
||||
@override
|
||||
String get extensionAuthor => 'Author';
|
||||
@@ -1503,7 +1542,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
@@ -1547,6 +1586,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||
|
||||
@override
|
||||
String get albumFolderStructureDescription =>
|
||||
'Choose how album folders are structured';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
|
||||
@@ -2073,7 +2116,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
'Get FLAC quality audio from installed download extensions';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2403,7 +2446,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
@@ -2756,14 +2799,14 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
'Folder named after Album Artist tag';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
'Folder named after Track Artist tag';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
@@ -2771,7 +2814,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
@@ -2813,6 +2856,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLyricsPlusDesc =>
|
||||
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
|
||||
@@ -2831,10 +2878,168 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Donate';
|
||||
String get settingsDonate => 'Support Development';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||
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
|
||||
String get tooltipLoveAll => 'Love All';
|
||||
@@ -2894,20 +3099,20 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choose storage mode for downloaded files.';
|
||||
'Choose where to save your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||
String get storageModeAppFolder => 'App Folder (Recommended)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||
String get storageModeAppFolderSubtitle =>
|
||||
'Saves to Music/SpotiFLAC by default';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'SAF folder';
|
||||
String get storageModeSaf => 'Custom Folder (SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Pick folder via Android Storage Access Framework';
|
||||
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
|
||||
|
||||
@override
|
||||
String downloadFilenameDescription(
|
||||
@@ -2919,62 +3124,73 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
Object track,
|
||||
Object year,
|
||||
) {
|
||||
return 'Customize how your files are named.';
|
||||
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||
String get downloadSeparateSinglesEnabled =>
|
||||
'Singles and EPs saved in a separate folder';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||
String get downloadSeparateSinglesDisabled =>
|
||||
'Singles and albums saved in the same folder';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
'A subfolder is created for each playlist';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
'All tracks saved directly to download folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
'Handled by folder organization setting';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||
'Using legacy TLS settings for older networks';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Off: strict HTTPS certificate validation (recommended)';
|
||||
'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
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
'Select a provider with quality options to enable this option';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
'Select a provider with quality options to choose audio quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Disabled while Embed Metadata is turned off';
|
||||
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation =>
|
||||
@@ -2982,11 +3198,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Append translated lyrics when available';
|
||||
'Chinese translation lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Use original lyrics only';
|
||||
'Original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
@@ -2994,21 +3210,21 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Append romanized lyrics when available';
|
||||
'Romanization lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Enable v1/v2 speaker and [bg:] tags';
|
||||
'Speaker labels included for duets and group tracks';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
'Standard lyrics without speaker labels';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
@@ -3025,46 +3241,45 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Filter contributing artists in Album Artist';
|
||||
String get downloadFilterContributing => 'Filter Contributing Artists';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Album Artist metadata uses primary artist only';
|
||||
'Contributing artists removed from Album Artist folder name';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Keep full Album Artist metadata value';
|
||||
'Full Album Artist string used';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||
String get downloadProvidersNoneEnabled => 'No providers enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Pause downloads on mobile data';
|
||||
'Downloads pause when on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Used as userCountry for SongLink API lookup.';
|
||||
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||
@@ -3450,7 +3665,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String notifTracksDownloadedSuccess(int count) {
|
||||
return '$count tracks downloaded successfully';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks downloaded successfully',
|
||||
one: '1 track downloaded successfully',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -4220,4 +4441,318 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsPrimaryProviderSubtitle =>
|
||||
'Service used when searching by track name.';
|
||||
'Service used for searching by track or album name';
|
||||
|
||||
@override
|
||||
String optionsUsingExtension(String extensionName) {
|
||||
@@ -142,7 +142,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
'Choose the default search provider to switch back from an extension';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallback => 'Auto Fallback';
|
||||
@@ -155,17 +155,19 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get optionsUseExtensionProviders => 'Use Extension Providers';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOn => 'Extensions will be tried first';
|
||||
String get optionsUseExtensionProvidersOn =>
|
||||
'Extension providers are enabled';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOff => 'Using built-in providers only';
|
||||
String get optionsUseExtensionProvidersOff =>
|
||||
'Extension providers are required';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyrics => 'Embed Lyrics';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyricsSubtitle =>
|
||||
'Embed synced lyrics into FLAC files';
|
||||
'Save synced lyrics alongside your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get optionsMaxQualityCover => 'Max Quality Cover';
|
||||
@@ -185,6 +187,43 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'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
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
@@ -206,21 +245,6 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'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 => 'Sequentiële (1 per keer)';
|
||||
|
||||
@override
|
||||
String optionsConcurrentParallel(int count) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsConcurrentWarning =>
|
||||
'Parallel downloaden kan leiden tot rate-limiting';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Extension Repo';
|
||||
|
||||
@@ -323,7 +347,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get aboutContributors => 'Contributors';
|
||||
|
||||
@override
|
||||
String get aboutMobileDeveloper => '';
|
||||
String get aboutMobileDeveloper => 'Mobile version developer';
|
||||
|
||||
@override
|
||||
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
|
||||
@@ -385,11 +409,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
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
|
||||
String get aboutSjdonadoDesc =>
|
||||
@@ -579,6 +603,15 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get previewPlay => 'Play preview';
|
||||
|
||||
@override
|
||||
String get previewStop => 'Stop preview';
|
||||
|
||||
@override
|
||||
String get previewUnavailable => 'Preview unavailable';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Discard';
|
||||
|
||||
@@ -953,7 +986,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
String get providerBuiltIn => 'Legacy';
|
||||
|
||||
@override
|
||||
String get providerExtension => 'Extension';
|
||||
@@ -1121,10 +1154,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get settingsAppearanceSubtitle => 'Theme, colors, display';
|
||||
|
||||
@override
|
||||
String get settingsDownloadSubtitle => 'Service, quality, filename format';
|
||||
String get settingsDownloadSubtitle => 'Service, quality, fallback';
|
||||
|
||||
@override
|
||||
String get settingsOptionsSubtitle => 'Fallback, lyrics, cover art, updates';
|
||||
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
|
||||
|
||||
@override
|
||||
String get settingsExtensionsSubtitle => 'Manage download providers';
|
||||
@@ -1346,10 +1379,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
String get extensionDefaultProvider => 'Default Search';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
||||
String get extensionDefaultProviderSubtitle =>
|
||||
'Use the default metadata search';
|
||||
|
||||
@override
|
||||
String get extensionAuthor => 'Author';
|
||||
@@ -1523,7 +1557,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
@@ -1567,6 +1601,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Album Folder Structure';
|
||||
|
||||
@override
|
||||
String get albumFolderStructureDescription =>
|
||||
'Choose how album folders are structured';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
|
||||
|
||||
@@ -2093,7 +2131,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Get FLAC quality audio from Tidal, Qobuz, or Deezer';
|
||||
'Get FLAC quality audio from installed download extensions';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2423,7 +2461,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
@@ -2776,14 +2814,14 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Artist folders use Album Artist when available';
|
||||
'Folder named after Album Artist tag';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Artist folders use Track Artist only';
|
||||
'Folder named after Track Artist tag';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
@@ -2791,7 +2829,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
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
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
@@ -2833,6 +2871,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLyricsPlusDesc =>
|
||||
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
|
||||
@@ -2851,10 +2893,168 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Donate';
|
||||
String get settingsDonate => 'Support Development';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||
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
|
||||
String get tooltipLoveAll => 'Love All';
|
||||
@@ -2914,20 +3114,20 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Choose storage mode for downloaded files.';
|
||||
'Choose where to save your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'App folder (non-SAF)';
|
||||
String get storageModeAppFolder => 'App Folder (Recommended)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||
String get storageModeAppFolderSubtitle =>
|
||||
'Saves to Music/SpotiFLAC by default';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'SAF folder';
|
||||
String get storageModeSaf => 'Custom Folder (SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Pick folder via Android Storage Access Framework';
|
||||
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
|
||||
|
||||
@override
|
||||
String downloadFilenameDescription(
|
||||
@@ -2939,62 +3139,73 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
Object track,
|
||||
Object year,
|
||||
) {
|
||||
return 'Customize how your files are named.';
|
||||
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||
String get downloadSeparateSinglesEnabled =>
|
||||
'Singles and EPs saved in a separate folder';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'All files in same structure';
|
||||
String get downloadSeparateSinglesDisabled =>
|
||||
'Singles and albums saved in the same folder';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Artist Name Filters';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Create playlist source folder';
|
||||
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
'A subfolder is created for each playlist';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
'All tracks saved directly to download folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
'Handled by folder organization setting';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Enabled: try HTTP + accept invalid TLS certificates (unsafe)';
|
||||
'Using legacy TLS settings for older networks';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Off: strict HTTPS certificate validation (recommended)';
|
||||
'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
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Select a built-in service to enable';
|
||||
'Select a provider with quality options to enable this option';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Select Tidal or Qobuz above to configure quality';
|
||||
'Select a provider with quality options to choose audio quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Disabled while Embed Metadata is turned off';
|
||||
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation =>
|
||||
@@ -3002,11 +3213,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Append translated lyrics when available';
|
||||
'Chinese translation lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Use original lyrics only';
|
||||
'Original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
@@ -3014,21 +3225,21 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Append romanized lyrics when available';
|
||||
'Romanization lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Enable v1/v2 speaker and [bg:] tags';
|
||||
'Speaker labels included for duets and group tracks';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Simplified word-by-word formatting';
|
||||
'Standard lyrics without speaker labels';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
@@ -3045,46 +3256,45 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original)';
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Filter contributing artists in Album Artist';
|
||||
String get downloadFilterContributing => 'Filter Contributing Artists';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Album Artist metadata uses primary artist only';
|
||||
'Contributing artists removed from Album Artist folder name';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Keep full Album Artist metadata value';
|
||||
'Full Album Artist string used';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||
String get downloadProvidersNoneEnabled => 'No providers enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Set preferred language code (example: en, es, ja). Leave empty for auto.';
|
||||
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Pause downloads on mobile data';
|
||||
'Downloads pause when on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Used as userCountry for SongLink API lookup.';
|
||||
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||
@@ -3470,7 +3680,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String notifTracksDownloadedSuccess(int count) {
|
||||
return '$count tracks downloaded successfully';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks downloaded successfully',
|
||||
one: '1 track downloaded successfully',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -4240,4 +4456,318 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
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';
|
||||
}
|
||||
|
||||
+1379
-106
File diff suppressed because it is too large
Load Diff
+751
-224
File diff suppressed because it is too large
Load Diff
+741
-206
File diff suppressed because it is too large
Load Diff
@@ -129,7 +129,7 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsPrimaryProviderSubtitle =>
|
||||
'Розширення будуть випробувані першими';
|
||||
'Service used for searching by track or album name';
|
||||
|
||||
@override
|
||||
String optionsUsingExtension(String extensionName) {
|
||||
@@ -137,15 +137,15 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
String get optionsDefaultSearchTab => 'Вкладка пошуку за замовчуванням';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
'Виберіть, яка вкладка відкриється першою для нових результатів пошуку.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Натисніть Deezer або Spotify, щоб повернутися до розширення';
|
||||
'Choose the default search provider to switch back from an extension';
|
||||
|
||||
@override
|
||||
String get optionsAutoFallback => 'Автоматичний резервний варіант';
|
||||
@@ -160,18 +160,18 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOn =>
|
||||
'Розширення будуть випробувані першими';
|
||||
'Extension providers are enabled';
|
||||
|
||||
@override
|
||||
String get optionsUseExtensionProvidersOff =>
|
||||
'Використати лише вбудованих постачальників';
|
||||
'Extension providers are required';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyrics => 'Вбудований текст пісні';
|
||||
|
||||
@override
|
||||
String get optionsEmbedLyricsSubtitle =>
|
||||
'Вбудовувати синхронізовані тексти пісень у файли FLAC';
|
||||
'Save synced lyrics alongside your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get optionsMaxQualityCover => 'Максимальна якість обкладинки';
|
||||
@@ -191,6 +191,43 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Вимкнено: немає тегів нормалізації гучності';
|
||||
|
||||
@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
|
||||
String get optionsArtistTagMode => 'Режим тегу виконавця';
|
||||
|
||||
@@ -212,21 +249,6 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
'Для FLAC та Opus на кожного виконавця додати окремий тег виконавця; MP3 та M4A залишаються об’єднаними.';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentDownloads => 'Кількість одночасних завантажень';
|
||||
|
||||
@override
|
||||
String get optionsConcurrentSequential => 'Послідовно (по одному за раз)';
|
||||
|
||||
@override
|
||||
String optionsConcurrentParallel(int count) {
|
||||
return '$count паралельних завантажень';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsConcurrentWarning =>
|
||||
'Паралельні завантаження можуть призвести до обмеження швидкості';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Репозиторій розширень';
|
||||
|
||||
@@ -395,11 +417,11 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutBinimumDesc =>
|
||||
'Творець QQDL та HiFi API. Без цього API завантажень Tidal\'а не існувало б!';
|
||||
'The creator of QQDL & HiFi API. This project helped shape lossless download support.';
|
||||
|
||||
@override
|
||||
String get aboutSachinsenalDesc =>
|
||||
'Оригінальний творець HiFi-проектів. Основа інтеграції Tidal!';
|
||||
'The original HiFi project creator. A foundation for lossless-source integration.';
|
||||
|
||||
@override
|
||||
String get aboutSjdonadoDesc =>
|
||||
@@ -590,6 +612,15 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get dialogDownload => 'Завантажити';
|
||||
|
||||
@override
|
||||
String get previewPlay => 'Play preview';
|
||||
|
||||
@override
|
||||
String get previewStop => 'Stop preview';
|
||||
|
||||
@override
|
||||
String get previewUnavailable => 'Preview unavailable';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Відхилити';
|
||||
|
||||
@@ -959,14 +990,14 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Виберіть, які встановлені розширення завантаження можна використовувати під час автоматичного відновлення до попереднього режиму. Вбудовані постачальники все одно дотримуються порядку пріоритетності, зазначеного вище.';
|
||||
'Choose which installed download extensions can be used during automatic fallback.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Тут перелічені лише ввімкнені розширення з можливістю завантаження через постачальника послуг.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Вбудований';
|
||||
String get providerBuiltIn => 'Legacy';
|
||||
|
||||
@override
|
||||
String get providerExtension => 'Розширення';
|
||||
@@ -1137,11 +1168,10 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
String get settingsAppearanceSubtitle => 'Тема, кольори, дисплей';
|
||||
|
||||
@override
|
||||
String get settingsDownloadSubtitle => 'Сервіс, якість, формат назви файлу';
|
||||
String get settingsDownloadSubtitle => 'Service, quality, fallback';
|
||||
|
||||
@override
|
||||
String get settingsOptionsSubtitle =>
|
||||
'Резервний варіант, тексти пісень, обкладинка, оновлення';
|
||||
String get settingsOptionsSubtitle => 'Fallback, metadata, lyrics, cover art';
|
||||
|
||||
@override
|
||||
String get settingsExtensionsSubtitle =>
|
||||
@@ -1368,10 +1398,11 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'Розширень не знайдено';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'За замовчуванням (Deezer)';
|
||||
String get extensionDefaultProvider => 'Default Search';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Використати вбудований пошук';
|
||||
String get extensionDefaultProviderSubtitle =>
|
||||
'Use the default metadata search';
|
||||
|
||||
@override
|
||||
String get extensionAuthor => 'Автор';
|
||||
@@ -1547,7 +1578,7 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Виберіть вихідний формат для завантажень Tidal 320 кбіт/с із втратами. Оригінальний потік AAC буде конвертовано у вибраний вами формат.';
|
||||
'Choose the output format for 320kbps lossy downloads. The original stream will be converted to your selected format when needed.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320 кбіт/с';
|
||||
@@ -1593,6 +1624,10 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get downloadAlbumFolderStructure => 'Структура папок альбому';
|
||||
|
||||
@override
|
||||
String get albumFolderStructureDescription =>
|
||||
'Виберіть структуру папок альбомів';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFolders =>
|
||||
'Використовувати виконавця альбому для папок';
|
||||
@@ -2129,7 +2164,7 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip2 =>
|
||||
'Отримуйте аудіо у якості FLAC з Tidal, Qobuz або Deezer';
|
||||
'Get FLAC quality audio from installed download extensions';
|
||||
|
||||
@override
|
||||
String get tutorialWelcomeTip3 =>
|
||||
@@ -2464,7 +2499,7 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Конвертувати в MP3, Opus, ALAC або FLAC';
|
||||
'Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Конвертувати аудіо';
|
||||
@@ -2821,14 +2856,14 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
|
||||
'Папки виконавців використовують \"Виконавець альбому\", коли це можливо';
|
||||
'Folder named after Album Artist tag';
|
||||
|
||||
@override
|
||||
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
|
||||
'Папки виконавців використовують лише виконавця доріжки';
|
||||
'Folder named after Track Artist tag';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Постачальники текстів пісень';
|
||||
String get lyricsProvidersTitle => 'Lyrics Provider Priority';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
@@ -2836,7 +2871,7 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get lyricsProvidersInfoText =>
|
||||
'Постачальники розширених текстів пісень завжди запускаються перед вбудованими постачальниками. Принаймні один постачальник має залишатися ввімкненим.';
|
||||
'Extension lyrics providers run before built-in lyrics providers. At least one provider must remain enabled.';
|
||||
|
||||
@override
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
@@ -2880,6 +2915,10 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (добре для китайських пісень, через проксі)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderLyricsPlusDesc =>
|
||||
'Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ, via proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Постачальник розширень';
|
||||
|
||||
@@ -2898,11 +2937,168 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
String get safMigrationSuccess => 'Папку завантажень оновлено до режиму SAF';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Пожертвувати кошти';
|
||||
String get settingsDonate => 'Support Development';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle =>
|
||||
'Підтримка розробки SpotiFLAC для мобільних пристроїв';
|
||||
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
|
||||
String get tooltipLoveAll => 'Уподобати всіх';
|
||||
@@ -2965,21 +3161,20 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadLocationSubtitle =>
|
||||
'Виберіть режим зберігання для завантажених файлів.';
|
||||
'Choose where to save your downloaded tracks';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolder => 'Папка додатку (не SAF)';
|
||||
String get storageModeAppFolder => 'App Folder (Recommended)';
|
||||
|
||||
@override
|
||||
String get storageModeAppFolderSubtitle =>
|
||||
'Використовувати шлях Music/SpotiFLAC за замовчуванням';
|
||||
'Saves to Music/SpotiFLAC by default';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'Папка SAF';
|
||||
String get storageModeSaf => 'Custom Folder (SAF)';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
'Вибрати папку через Android Storage Access Framework';
|
||||
String get storageModeSafSubtitle => 'Pick any folder, including SD card';
|
||||
|
||||
@override
|
||||
String downloadFilenameDescription(
|
||||
@@ -2991,73 +3186,84 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
Object track,
|
||||
Object year,
|
||||
) {
|
||||
return 'Налаштувати спосіб іменування ваших файлів.';
|
||||
return 'Use $artist, $title, $album, $track, $year, $date, $disc as placeholders.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Натисніть, щоб вставити тег:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Папки «Альбоми» та «Сингли»';
|
||||
String get downloadSeparateSinglesEnabled =>
|
||||
'Singles and EPs saved in a separate folder';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesDisabled => 'Всі файли в одній структурі';
|
||||
String get downloadSeparateSinglesDisabled =>
|
||||
'Singles and albums saved in the same folder';
|
||||
|
||||
@override
|
||||
String get downloadArtistNameFilters => 'Фільтри імені виконавця';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Створити папку джерела списку відтворення';
|
||||
String get downloadCreatePlaylistSourceFolder => 'Playlist Source Folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Завантаження списків відтворення використовує Playlist/ плюс вашу звичайну структуру папок.';
|
||||
'A subfolder is created for each playlist';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Завантаження списків відтворення використовують лише звичайну структуру папок.';
|
||||
'All tracks saved directly to download folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'За допомогою списку відтворення завантаження вже розміщуються в папці зі списком відтворення.';
|
||||
'Handled by folder organization setting';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'Регіон SongLink';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Режим сумісності з мережею';
|
||||
String get downloadNetworkCompatibilityMode => 'Network Compatibility Mode';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
'Увімкнено: спробувати HTTP + прийняти недійсні сертифікати TLS (небезпечно)';
|
||||
'Using legacy TLS settings for older networks';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeDisabled =>
|
||||
'Вимкнено: сувора перевірка сертифіката HTTPS (рекомендовано)';
|
||||
'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
|
||||
String get downloadSelectServiceToEnable =>
|
||||
'Виберіть вбудовану службу, яку потрібно ввімкнути';
|
||||
'Select a provider with quality options to enable this option';
|
||||
|
||||
@override
|
||||
String get downloadSelectTidalQobuz =>
|
||||
'Виберіть Tidal або Qobuz вище, щоб налаштувати якість';
|
||||
'Select a provider with quality options to choose audio quality';
|
||||
|
||||
@override
|
||||
String get downloadEmbedLyricsDisabled =>
|
||||
'Вимкнено, якщо вимкнено функцію «Вбудувати метадані»';
|
||||
String get downloadEmbedLyricsDisabled => 'Enable metadata embedding first';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslation => 'Netease: Включити переклад';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationEnabled =>
|
||||
'Додати перекладені тексти пісень, коли вони доступні';
|
||||
'Chinese translation lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeTranslationDisabled =>
|
||||
'Використовувати лише оригінальні тексти пісень';
|
||||
'Original lyrics only';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanization =>
|
||||
@@ -3065,22 +3271,21 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationEnabled =>
|
||||
'Додати романізовані тексти пісень, коли це можливо';
|
||||
'Romanization lines included';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Вимкнути';
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'No romanization';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson =>
|
||||
'Apple/QQ Багатокористувацький переклад слово за словом';
|
||||
String get downloadAppleQqMultiPerson => 'Apple / QQ: Multi-Person Lyrics';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonEnabled =>
|
||||
'Увімкнути теги динаміка v1/v2 та [bg:]';
|
||||
'Speaker labels included for duets and group tracks';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Спрощене послівне форматування';
|
||||
'Standard lyrics without speaker labels';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
@@ -3097,46 +3302,45 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
String get downloadMusixmatchLanguage => 'Мова Musixmatch';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageAuto => 'Авто (оригінал)';
|
||||
String get downloadMusixmatchLanguageAuto => 'Auto (original language)';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributing =>
|
||||
'Фільтрувати виконавців-учасників у розділі «Виконавець альбому»';
|
||||
String get downloadFilterContributing => 'Filter Contributing Artists';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingEnabled =>
|
||||
'Метадані виконавця альбому використовують лише основного виконавця';
|
||||
'Contributing artists removed from Album Artist folder name';
|
||||
|
||||
@override
|
||||
String get downloadFilterContributingDisabled =>
|
||||
'Зберегти повне значення метаданих виконавця альбому';
|
||||
'Full Album Artist string used';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'Не ввімкнено';
|
||||
String get downloadProvidersNoneEnabled => 'No providers enabled';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Код мови';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'авто / en / es / ja';
|
||||
String get downloadMusixmatchLanguageHint => 'e.g. en, de, ja';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageDesc =>
|
||||
'Встановити потрібний код мови (наприклад: en, es, ja). Залиште поле порожнім для автоматичного вибору.';
|
||||
'Enter a BCP-47 language code (e.g. en, de, ja) to request translated lyrics from Musixmatch.';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchAuto => 'Авто';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'Wi-Fi + мобільний інтернет';
|
||||
String get downloadNetworkAnySubtitle => 'Use WiFi or mobile data';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
'Призупинити завантаження через мобільний інтернет';
|
||||
'Downloads pause when on mobile data';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegionDesc =>
|
||||
'Використовувати як userCountry для пошуку SongLink API.';
|
||||
'Region used when resolving track links via SongLink. Choose the country where your streaming services are available.';
|
||||
|
||||
@override
|
||||
String get snackbarUnsupportedAudioFormat => 'Непідтримуваний аудіоформат';
|
||||
@@ -3529,7 +3733,13 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String notifTracksDownloadedSuccess(int count) {
|
||||
return '$count треки успішно завантажено';
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count tracks downloaded successfully',
|
||||
one: '1 track downloaded successfully',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -3609,7 +3819,7 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String notifDownloadingUpdate(String version) {
|
||||
return 'Завантаження SpotiFLAC Mobile v$version';
|
||||
return 'Downloading SpotiFLAC Mobile v$version';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -3622,7 +3832,7 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String notifUpdateReadyBody(String version) {
|
||||
return 'SpotiFLAC Mobile v$version завантажений. Натисніть щоб установити.';
|
||||
return 'SpotiFLAC Mobile v$version downloaded. Tap to install.';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -4299,4 +4509,318 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
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';
|
||||
}
|
||||
|
||||
+2219
-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
+703
-53
@@ -174,9 +174,9 @@
|
||||
"@optionsDefaultSearchTabSubtitle": {
|
||||
"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": {
|
||||
"description": "Hint to switch back to built-in providers"
|
||||
"description": "Hint to switch back from extension search"
|
||||
},
|
||||
"optionsAutoFallback": "Auto Fallback",
|
||||
"@optionsAutoFallback": {
|
||||
@@ -188,15 +188,15 @@
|
||||
},
|
||||
"optionsUseExtensionProviders": "Use Extension Providers",
|
||||
"@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": {
|
||||
"description": "Status when extension providers enabled"
|
||||
},
|
||||
"optionsUseExtensionProvidersOff": "Using built-in providers only",
|
||||
"optionsUseExtensionProvidersOff": "Extension providers are required",
|
||||
"@optionsUseExtensionProvidersOff": {
|
||||
"description": "Status when extension providers disabled"
|
||||
"description": "Legacy status when extension providers would be disabled"
|
||||
},
|
||||
"optionsEmbedLyrics": "Embed Lyrics",
|
||||
"@optionsEmbedLyrics": {
|
||||
@@ -226,6 +226,64 @@
|
||||
"@optionsReplayGainSubtitleOff": {
|
||||
"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": {
|
||||
"description": "Setting title for how artist metadata is written into files"
|
||||
@@ -250,27 +308,6 @@
|
||||
"@optionsArtistTagModeSplitVorbisSubtitle": {
|
||||
"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": {
|
||||
"description": "Show/hide store tab"
|
||||
@@ -486,11 +523,11 @@
|
||||
"@aboutVersion": {
|
||||
"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": {
|
||||
"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": {
|
||||
"description": "Credit description for sachinsenal0x64"
|
||||
},
|
||||
@@ -735,6 +772,18 @@
|
||||
"@dialogDownload": {
|
||||
"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": {
|
||||
"description": "Dialog button - discard changes"
|
||||
@@ -1235,9 +1284,9 @@
|
||||
"@providerPriorityFallbackExtensionsHint": {
|
||||
"description": "Hint below the extension fallback selection list"
|
||||
},
|
||||
"providerBuiltIn": "Built-in",
|
||||
"providerBuiltIn": "Legacy",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
"description": "Legacy label retained for old generated localization compatibility"
|
||||
},
|
||||
"providerExtension": "Extension",
|
||||
"@providerExtension": {
|
||||
@@ -1759,11 +1808,11 @@
|
||||
"@storeEmptyNoResults": {
|
||||
"description": "Message when search/filter returns no results"
|
||||
},
|
||||
"extensionDefaultProvider": "Default (Deezer)",
|
||||
"extensionDefaultProvider": "Default Search",
|
||||
"@extensionDefaultProvider": {
|
||||
"description": "Default search provider option"
|
||||
},
|
||||
"extensionDefaultProviderSubtitle": "Use built-in search",
|
||||
"extensionDefaultProviderSubtitle": "Use the default metadata search",
|
||||
"@extensionDefaultProviderSubtitle": {
|
||||
"description": "Subtitle for default provider"
|
||||
},
|
||||
@@ -1992,51 +2041,51 @@
|
||||
},
|
||||
"downloadLossy320": "Lossy 320kbps",
|
||||
"@downloadLossy320": {
|
||||
"description": "Quality option label for Tidal lossy 320kbps"
|
||||
"description": "Quality option label for lossy 320kbps"
|
||||
},
|
||||
"downloadLossyFormat": "Lossy Format",
|
||||
"@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": {
|
||||
"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": {
|
||||
"description": "Description in the Tidal lossy format picker"
|
||||
"description": "Description in the lossy format picker"
|
||||
},
|
||||
"downloadLossyMp3": "MP3 320kbps",
|
||||
"@downloadLossyMp3": {
|
||||
"description": "Tidal lossy format option - MP3 320kbps"
|
||||
"description": "Lossy format option - MP3 320kbps"
|
||||
},
|
||||
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
||||
"@downloadLossyMp3Subtitle": {
|
||||
"description": "Subtitle for MP3 320kbps Tidal lossy option"
|
||||
"description": "Subtitle for MP3 320kbps lossy option"
|
||||
},
|
||||
"downloadLossyAac": "AAC/M4A 320kbps",
|
||||
"@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": {
|
||||
"description": "Subtitle for AAC/M4A 320kbps Tidal lossy option"
|
||||
"description": "Subtitle for AAC/M4A 320kbps lossy option"
|
||||
},
|
||||
"downloadLossyOpus256": "Opus 256kbps",
|
||||
"@downloadLossyOpus256": {
|
||||
"description": "Tidal lossy format option - Opus 256kbps"
|
||||
"description": "Lossy format option - Opus 256kbps"
|
||||
},
|
||||
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
||||
"@downloadLossyOpus256Subtitle": {
|
||||
"description": "Subtitle for Opus 256kbps Tidal lossy option"
|
||||
"description": "Subtitle for Opus 256kbps lossy option"
|
||||
},
|
||||
"downloadLossyOpus128": "Opus 128kbps",
|
||||
"@downloadLossyOpus128": {
|
||||
"description": "Tidal lossy format option - Opus 128kbps"
|
||||
"description": "Lossy format option - Opus 128kbps"
|
||||
},
|
||||
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
||||
"@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": {
|
||||
@@ -2058,6 +2107,10 @@
|
||||
"@downloadAlbumFolderStructure": {
|
||||
"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": {
|
||||
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
|
||||
@@ -2519,7 +2572,7 @@
|
||||
"@libraryAbout": {
|
||||
"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": {
|
||||
"description": "Description of local library feature"
|
||||
},
|
||||
@@ -2745,7 +2798,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"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": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -3704,7 +3757,7 @@
|
||||
"@lyricsProvidersDescription": {
|
||||
"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": {
|
||||
"description": "Info tip on lyrics provider priority page"
|
||||
},
|
||||
@@ -3758,6 +3811,10 @@
|
||||
"@lyricsProviderQqMusicDesc": {
|
||||
"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": {
|
||||
"description": "Generic description for extension-based lyrics providers"
|
||||
@@ -3786,6 +3843,169 @@
|
||||
"@settingsDonateSubtitle": {
|
||||
"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": {
|
||||
"description": "Tooltip for the Love All button on album/playlist screens"
|
||||
@@ -3942,13 +4162,25 @@
|
||||
"@downloadNetworkCompatibilityModeDisabled": {
|
||||
"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": {
|
||||
"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": {
|
||||
"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": {
|
||||
@@ -4358,7 +4590,7 @@
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
"description": "Extensions page - subtitle for default metadata search provider option",
|
||||
"placeholders": {
|
||||
"providerName": {
|
||||
"type": "String"
|
||||
@@ -5554,5 +5786,423 @@
|
||||
"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": {
|
||||
"description": "Hint to switch back to built-in providers"
|
||||
"description": "Hint to switch back from extension search"
|
||||
},
|
||||
"optionsAutoFallback": "Auto Fallback",
|
||||
"@optionsAutoFallback": {
|
||||
@@ -156,15 +156,15 @@
|
||||
},
|
||||
"optionsUseExtensionProviders": "Use Extension Providers",
|
||||
"@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": {
|
||||
"description": "Status when extension providers enabled"
|
||||
},
|
||||
"optionsUseExtensionProvidersOff": "Using built-in providers only",
|
||||
"optionsUseExtensionProvidersOff": "Extension providers are required",
|
||||
"@optionsUseExtensionProvidersOff": {
|
||||
"description": "Status when extension providers disabled"
|
||||
"description": "Legacy status when extension providers would be disabled"
|
||||
},
|
||||
"optionsEmbedLyrics": "Embed Lyrics",
|
||||
"@optionsEmbedLyrics": {
|
||||
@@ -182,27 +182,6 @@
|
||||
"@optionsMaxQualityCoverSubtitle": {
|
||||
"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": {
|
||||
"description": "Show/hide store tab"
|
||||
@@ -390,11 +369,11 @@
|
||||
"@aboutVersion": {
|
||||
"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": {
|
||||
"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": {
|
||||
"description": "Credit description for sachinsenal0x64"
|
||||
},
|
||||
@@ -999,9 +978,9 @@
|
||||
"@providerPriorityInfo": {
|
||||
"description": "Info tip about fallback behavior"
|
||||
},
|
||||
"providerBuiltIn": "Built-in",
|
||||
"providerBuiltIn": "Legacy",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
"description": "Legacy label retained for old generated localization compatibility"
|
||||
},
|
||||
"providerExtension": "Extension",
|
||||
"@providerExtension": {
|
||||
@@ -1394,11 +1373,11 @@
|
||||
"@storeClearFilters": {
|
||||
"description": "Button to clear all filters"
|
||||
},
|
||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
||||
"extensionDefaultProvider": "Default Search",
|
||||
"@extensionDefaultProvider": {
|
||||
"description": "Default search provider option"
|
||||
},
|
||||
"extensionDefaultProviderSubtitle": "Use built-in search",
|
||||
"extensionDefaultProviderSubtitle": "Use the default metadata search",
|
||||
"@extensionDefaultProviderSubtitle": {
|
||||
"description": "Subtitle for default provider"
|
||||
},
|
||||
@@ -1613,6 +1592,10 @@
|
||||
"@downloadAlbumFolderStructure": {
|
||||
"description": "Setting - album folder organization"
|
||||
},
|
||||
"albumFolderStructureDescription": "Choose how album folders are structured",
|
||||
"@albumFolderStructureDescription": {
|
||||
"description": "Album folder structure picker description"
|
||||
},
|
||||
"downloadSelectQuality": "Select Quality",
|
||||
"@downloadSelectQuality": {
|
||||
"description": "Dialog title - choose audio quality"
|
||||
@@ -2071,43 +2054,43 @@
|
||||
},
|
||||
"downloadLossy320": "Lossy 320kbps",
|
||||
"@downloadLossy320": {
|
||||
"description": "Quality option label for Tidal lossy 320kbps"
|
||||
"description": "Quality option label for lossy 320kbps"
|
||||
},
|
||||
"downloadLossyFormat": "Lossy Format",
|
||||
"@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": {
|
||||
"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": {
|
||||
"description": "Description in the Tidal lossy format picker"
|
||||
"description": "Description in the lossy format picker"
|
||||
},
|
||||
"downloadLossyMp3": "MP3 320kbps",
|
||||
"@downloadLossyMp3": {
|
||||
"description": "Tidal lossy format option - MP3 320kbps"
|
||||
"description": "lossy format option - MP3 320kbps"
|
||||
},
|
||||
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
||||
"@downloadLossyMp3Subtitle": {
|
||||
"description": "Subtitle for MP3 320kbps Tidal lossy option"
|
||||
"description": "Subtitle for MP3 320kbps lossy option"
|
||||
},
|
||||
"downloadLossyOpus256": "Opus 256kbps",
|
||||
"@downloadLossyOpus256": {
|
||||
"description": "Tidal lossy format option - Opus 256kbps"
|
||||
"description": "lossy format option - Opus 256kbps"
|
||||
},
|
||||
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
||||
"@downloadLossyOpus256Subtitle": {
|
||||
"description": "Subtitle for Opus 256kbps Tidal lossy option"
|
||||
"description": "Subtitle for Opus 256kbps lossy option"
|
||||
},
|
||||
"downloadLossyOpus128": "Opus 128kbps",
|
||||
"@downloadLossyOpus128": {
|
||||
"description": "Tidal lossy format option - Opus 128kbps"
|
||||
"description": "lossy format option - Opus 128kbps"
|
||||
},
|
||||
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
||||
"@downloadLossyOpus128Subtitle": {
|
||||
"description": "Subtitle for Opus 128kbps Tidal lossy option"
|
||||
"description": "Subtitle for Opus 128kbps lossy option"
|
||||
},
|
||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||
"@downloadUseAlbumArtistForFolders": {
|
||||
@@ -2697,7 +2680,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"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": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -3600,7 +3583,7 @@
|
||||
"@lyricsProvidersDescription": {
|
||||
"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": {
|
||||
"description": "Info tip on lyrics provider priority page"
|
||||
},
|
||||
@@ -3838,13 +3821,13 @@
|
||||
"@downloadNetworkCompatibilityModeDisabled": {
|
||||
"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": {
|
||||
"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": {
|
||||
"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": {
|
||||
@@ -4206,7 +4189,7 @@
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
"description": "Extensions page - subtitle for default metadata search provider option",
|
||||
"placeholders": {
|
||||
"providerName": {
|
||||
"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
+1498
-132
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": {
|
||||
"description": "Hint to switch back to built-in providers"
|
||||
"description": "Hint to switch back from extension search"
|
||||
},
|
||||
"optionsAutoFallback": "Auto Fallback",
|
||||
"@optionsAutoFallback": {
|
||||
@@ -156,15 +156,15 @@
|
||||
},
|
||||
"optionsUseExtensionProviders": "Use Extension Providers",
|
||||
"@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": {
|
||||
"description": "Status when extension providers enabled"
|
||||
},
|
||||
"optionsUseExtensionProvidersOff": "Using built-in providers only",
|
||||
"optionsUseExtensionProvidersOff": "Extension providers are required",
|
||||
"@optionsUseExtensionProvidersOff": {
|
||||
"description": "Status when extension providers disabled"
|
||||
"description": "Legacy status when extension providers would be disabled"
|
||||
},
|
||||
"optionsEmbedLyrics": "Embed Lyrics",
|
||||
"@optionsEmbedLyrics": {
|
||||
@@ -182,27 +182,6 @@
|
||||
"@optionsMaxQualityCoverSubtitle": {
|
||||
"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": {
|
||||
"description": "Show/hide store tab"
|
||||
@@ -390,11 +369,11 @@
|
||||
"@aboutVersion": {
|
||||
"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": {
|
||||
"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": {
|
||||
"description": "Credit description for sachinsenal0x64"
|
||||
},
|
||||
@@ -999,9 +978,9 @@
|
||||
"@providerPriorityInfo": {
|
||||
"description": "Info tip about fallback behavior"
|
||||
},
|
||||
"providerBuiltIn": "Built-in",
|
||||
"providerBuiltIn": "Legacy",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
"description": "Legacy label retained for old generated localization compatibility"
|
||||
},
|
||||
"providerExtension": "Extension",
|
||||
"@providerExtension": {
|
||||
@@ -1394,11 +1373,11 @@
|
||||
"@storeClearFilters": {
|
||||
"description": "Button to clear all filters"
|
||||
},
|
||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
||||
"extensionDefaultProvider": "Default Search",
|
||||
"@extensionDefaultProvider": {
|
||||
"description": "Default search provider option"
|
||||
},
|
||||
"extensionDefaultProviderSubtitle": "Use built-in search",
|
||||
"extensionDefaultProviderSubtitle": "Use the default metadata search",
|
||||
"@extensionDefaultProviderSubtitle": {
|
||||
"description": "Subtitle for default provider"
|
||||
},
|
||||
@@ -1613,6 +1592,10 @@
|
||||
"@downloadAlbumFolderStructure": {
|
||||
"description": "Setting - album folder organization"
|
||||
},
|
||||
"albumFolderStructureDescription": "Choose how album folders are structured",
|
||||
"@albumFolderStructureDescription": {
|
||||
"description": "Album folder structure picker description"
|
||||
},
|
||||
"downloadSelectQuality": "Select Quality",
|
||||
"@downloadSelectQuality": {
|
||||
"description": "Dialog title - choose audio quality"
|
||||
@@ -2071,43 +2054,43 @@
|
||||
},
|
||||
"downloadLossy320": "Lossy 320kbps",
|
||||
"@downloadLossy320": {
|
||||
"description": "Quality option label for Tidal lossy 320kbps"
|
||||
"description": "Quality option label for lossy 320kbps"
|
||||
},
|
||||
"downloadLossyFormat": "Lossy Format",
|
||||
"@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": {
|
||||
"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": {
|
||||
"description": "Description in the Tidal lossy format picker"
|
||||
"description": "Description in the lossy format picker"
|
||||
},
|
||||
"downloadLossyMp3": "MP3 320kbps",
|
||||
"@downloadLossyMp3": {
|
||||
"description": "Tidal lossy format option - MP3 320kbps"
|
||||
"description": "lossy format option - MP3 320kbps"
|
||||
},
|
||||
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
||||
"@downloadLossyMp3Subtitle": {
|
||||
"description": "Subtitle for MP3 320kbps Tidal lossy option"
|
||||
"description": "Subtitle for MP3 320kbps lossy option"
|
||||
},
|
||||
"downloadLossyOpus256": "Opus 256kbps",
|
||||
"@downloadLossyOpus256": {
|
||||
"description": "Tidal lossy format option - Opus 256kbps"
|
||||
"description": "lossy format option - Opus 256kbps"
|
||||
},
|
||||
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
||||
"@downloadLossyOpus256Subtitle": {
|
||||
"description": "Subtitle for Opus 256kbps Tidal lossy option"
|
||||
"description": "Subtitle for Opus 256kbps lossy option"
|
||||
},
|
||||
"downloadLossyOpus128": "Opus 128kbps",
|
||||
"@downloadLossyOpus128": {
|
||||
"description": "Tidal lossy format option - Opus 128kbps"
|
||||
"description": "lossy format option - Opus 128kbps"
|
||||
},
|
||||
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
||||
"@downloadLossyOpus128Subtitle": {
|
||||
"description": "Subtitle for Opus 128kbps Tidal lossy option"
|
||||
"description": "Subtitle for Opus 128kbps lossy option"
|
||||
},
|
||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||
"@downloadUseAlbumArtistForFolders": {
|
||||
@@ -2697,7 +2680,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"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": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -3600,7 +3583,7 @@
|
||||
"@lyricsProvidersDescription": {
|
||||
"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": {
|
||||
"description": "Info tip on lyrics provider priority page"
|
||||
},
|
||||
@@ -3838,13 +3821,13 @@
|
||||
"@downloadNetworkCompatibilityModeDisabled": {
|
||||
"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": {
|
||||
"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": {
|
||||
"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": {
|
||||
@@ -4206,7 +4189,7 @@
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
"description": "Extensions page - subtitle for default metadata search provider option",
|
||||
"placeholders": {
|
||||
"providerName": {
|
||||
"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": {
|
||||
"description": "Hint to switch back to built-in providers"
|
||||
"description": "Hint to switch back from extension search"
|
||||
},
|
||||
"optionsAutoFallback": "Auto Fallback",
|
||||
"@optionsAutoFallback": {
|
||||
@@ -156,15 +156,15 @@
|
||||
},
|
||||
"optionsUseExtensionProviders": "Use Extension Providers",
|
||||
"@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": {
|
||||
"description": "Status when extension providers enabled"
|
||||
},
|
||||
"optionsUseExtensionProvidersOff": "Using built-in providers only",
|
||||
"optionsUseExtensionProvidersOff": "Extension providers are required",
|
||||
"@optionsUseExtensionProvidersOff": {
|
||||
"description": "Status when extension providers disabled"
|
||||
"description": "Legacy status when extension providers would be disabled"
|
||||
},
|
||||
"optionsEmbedLyrics": "Embed Lyrics",
|
||||
"@optionsEmbedLyrics": {
|
||||
@@ -182,27 +182,6 @@
|
||||
"@optionsMaxQualityCoverSubtitle": {
|
||||
"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": {
|
||||
"description": "Show/hide store tab"
|
||||
@@ -390,11 +369,11 @@
|
||||
"@aboutVersion": {
|
||||
"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": {
|
||||
"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": {
|
||||
"description": "Credit description for sachinsenal0x64"
|
||||
},
|
||||
@@ -999,9 +978,9 @@
|
||||
"@providerPriorityInfo": {
|
||||
"description": "Info tip about fallback behavior"
|
||||
},
|
||||
"providerBuiltIn": "Built-in",
|
||||
"providerBuiltIn": "Legacy",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
"description": "Legacy label retained for old generated localization compatibility"
|
||||
},
|
||||
"providerExtension": "Extension",
|
||||
"@providerExtension": {
|
||||
@@ -1394,11 +1373,11 @@
|
||||
"@storeClearFilters": {
|
||||
"description": "Button to clear all filters"
|
||||
},
|
||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
||||
"extensionDefaultProvider": "Default Search",
|
||||
"@extensionDefaultProvider": {
|
||||
"description": "Default search provider option"
|
||||
},
|
||||
"extensionDefaultProviderSubtitle": "Use built-in search",
|
||||
"extensionDefaultProviderSubtitle": "Use the default metadata search",
|
||||
"@extensionDefaultProviderSubtitle": {
|
||||
"description": "Subtitle for default provider"
|
||||
},
|
||||
@@ -1613,6 +1592,10 @@
|
||||
"@downloadAlbumFolderStructure": {
|
||||
"description": "Setting - album folder organization"
|
||||
},
|
||||
"albumFolderStructureDescription": "Choose how album folders are structured",
|
||||
"@albumFolderStructureDescription": {
|
||||
"description": "Album folder structure picker description"
|
||||
},
|
||||
"downloadSelectQuality": "Select Quality",
|
||||
"@downloadSelectQuality": {
|
||||
"description": "Dialog title - choose audio quality"
|
||||
@@ -2071,43 +2054,43 @@
|
||||
},
|
||||
"downloadLossy320": "Lossy 320kbps",
|
||||
"@downloadLossy320": {
|
||||
"description": "Quality option label for Tidal lossy 320kbps"
|
||||
"description": "Quality option label for lossy 320kbps"
|
||||
},
|
||||
"downloadLossyFormat": "Lossy Format",
|
||||
"@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": {
|
||||
"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": {
|
||||
"description": "Description in the Tidal lossy format picker"
|
||||
"description": "Description in the lossy format picker"
|
||||
},
|
||||
"downloadLossyMp3": "MP3 320kbps",
|
||||
"@downloadLossyMp3": {
|
||||
"description": "Tidal lossy format option - MP3 320kbps"
|
||||
"description": "lossy format option - MP3 320kbps"
|
||||
},
|
||||
"downloadLossyMp3Subtitle": "Best compatibility, ~10MB per track",
|
||||
"@downloadLossyMp3Subtitle": {
|
||||
"description": "Subtitle for MP3 320kbps Tidal lossy option"
|
||||
"description": "Subtitle for MP3 320kbps lossy option"
|
||||
},
|
||||
"downloadLossyOpus256": "Opus 256kbps",
|
||||
"@downloadLossyOpus256": {
|
||||
"description": "Tidal lossy format option - Opus 256kbps"
|
||||
"description": "lossy format option - Opus 256kbps"
|
||||
},
|
||||
"downloadLossyOpus256Subtitle": "Best quality Opus, ~8MB per track",
|
||||
"@downloadLossyOpus256Subtitle": {
|
||||
"description": "Subtitle for Opus 256kbps Tidal lossy option"
|
||||
"description": "Subtitle for Opus 256kbps lossy option"
|
||||
},
|
||||
"downloadLossyOpus128": "Opus 128kbps",
|
||||
"@downloadLossyOpus128": {
|
||||
"description": "Tidal lossy format option - Opus 128kbps"
|
||||
"description": "lossy format option - Opus 128kbps"
|
||||
},
|
||||
"downloadLossyOpus128Subtitle": "Smallest size, ~4MB per track",
|
||||
"@downloadLossyOpus128Subtitle": {
|
||||
"description": "Subtitle for Opus 128kbps Tidal lossy option"
|
||||
"description": "Subtitle for Opus 128kbps lossy option"
|
||||
},
|
||||
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
|
||||
"@downloadUseAlbumArtistForFolders": {
|
||||
@@ -2697,7 +2680,7 @@
|
||||
"@tutorialWelcomeTip1": {
|
||||
"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": {
|
||||
"description": "Tutorial welcome tip 2"
|
||||
},
|
||||
@@ -3600,7 +3583,7 @@
|
||||
"@lyricsProvidersDescription": {
|
||||
"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": {
|
||||
"description": "Info tip on lyrics provider priority page"
|
||||
},
|
||||
@@ -3838,13 +3821,13 @@
|
||||
"@downloadNetworkCompatibilityModeDisabled": {
|
||||
"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": {
|
||||
"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": {
|
||||
"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": {
|
||||
@@ -4206,7 +4189,7 @@
|
||||
},
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
"description": "Extensions page - subtitle for default metadata search provider option",
|
||||
"placeholders": {
|
||||
"providerName": {
|
||||
"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.
|
||||
const List<Locale> filteredSupportedLocales = <Locale>[
|
||||
Locale('en'),
|
||||
Locale('ru'),
|
||||
Locale('fr'),
|
||||
Locale('de'),
|
||||
Locale('es', 'ES'),
|
||||
Locale('id'),
|
||||
Locale('pt', 'PT'),
|
||||
Locale('ja'),
|
||||
Locale('tr'),
|
||||
Locale('uk'),
|
||||
Locale('ru'),
|
||||
Locale('tr'),
|
||||
Locale('id'),
|
||||
Locale('ja'),
|
||||
Locale('pt', 'PT'),
|
||||
];
|
||||
|
||||
/// Set of locale codes for quick lookup.
|
||||
const Set<String> filteredLocaleCodes = <String>{
|
||||
'en',
|
||||
'ru',
|
||||
'fr',
|
||||
'de',
|
||||
'es_ES',
|
||||
'id',
|
||||
'pt_PT',
|
||||
'ja',
|
||||
'tr',
|
||||
'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/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/utils/local_library_scan_prefs.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final runtimeProfile = await _resolveRuntimeProfile();
|
||||
_configureImageCache(runtimeProfile);
|
||||
final _log = AppLogger('Main');
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
child: _EagerInitialization(
|
||||
child: SpotiFLACApp(
|
||||
disableOverscrollEffects: runtimeProfile.disableOverscrollEffects,
|
||||
void main() {
|
||||
// Catch uncaught Dart errors so a failing async path is logged, not fatal.
|
||||
// Native (Go) crashes still can't be caught here.
|
||||
runZonedGuarded(
|
||||
() 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');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,14 @@ enum DownloadStatus {
|
||||
skipped,
|
||||
}
|
||||
|
||||
enum DownloadErrorType { unknown, notFound, rateLimit, network, permission }
|
||||
enum DownloadErrorType {
|
||||
unknown,
|
||||
notFound,
|
||||
rateLimit,
|
||||
network,
|
||||
permission,
|
||||
verificationRequired,
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class DownloadItem {
|
||||
@@ -22,14 +29,15 @@ class DownloadItem {
|
||||
final DownloadStatus status;
|
||||
final double progress;
|
||||
final double speedMBps;
|
||||
final int bytesReceived; // Bytes downloaded so far
|
||||
final int bytesReceived;
|
||||
final int bytesTotal; // Total bytes when the server provides content length
|
||||
final String? filePath;
|
||||
final String? error;
|
||||
final DownloadErrorType? errorType;
|
||||
final DateTime createdAt;
|
||||
final String? qualityOverride; // Override quality for this specific download
|
||||
final String? playlistName; // Playlist context for folder organization
|
||||
final String? qualityOverride;
|
||||
final String? playlistName;
|
||||
final int? playlistPosition; // 1-based position in the source playlist
|
||||
|
||||
const DownloadItem({
|
||||
required this.id,
|
||||
@@ -46,6 +54,7 @@ class DownloadItem {
|
||||
required this.createdAt,
|
||||
this.qualityOverride,
|
||||
this.playlistName,
|
||||
this.playlistPosition,
|
||||
});
|
||||
|
||||
DownloadItem copyWith({
|
||||
@@ -63,6 +72,7 @@ class DownloadItem {
|
||||
DateTime? createdAt,
|
||||
String? qualityOverride,
|
||||
String? playlistName,
|
||||
int? playlistPosition,
|
||||
}) {
|
||||
return DownloadItem(
|
||||
id: id ?? this.id,
|
||||
@@ -79,6 +89,7 @@ class DownloadItem {
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
qualityOverride: qualityOverride ?? this.qualityOverride,
|
||||
playlistName: playlistName ?? this.playlistName,
|
||||
playlistPosition: playlistPosition ?? this.playlistPosition,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,6 +105,8 @@ class DownloadItem {
|
||||
return 'Connection failed, check your internet';
|
||||
case DownloadErrorType.permission:
|
||||
return 'Cannot write to folder, check storage permission';
|
||||
case DownloadErrorType.verificationRequired:
|
||||
return 'Verification required. Open the extension and complete the security check.';
|
||||
default:
|
||||
return error ?? 'An error occurred';
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
qualityOverride: json['qualityOverride'] as String?,
|
||||
playlistName: json['playlistName'] as String?,
|
||||
playlistPosition: (json['playlistPosition'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
||||
@@ -41,6 +42,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
|
||||
'createdAt': instance.createdAt.toIso8601String(),
|
||||
'qualityOverride': instance.qualityOverride,
|
||||
'playlistName': instance.playlistName,
|
||||
'playlistPosition': instance.playlistPosition,
|
||||
};
|
||||
|
||||
const _$DownloadStatusEnumMap = {
|
||||
@@ -58,4 +60,5 @@ const _$DownloadErrorTypeEnumMap = {
|
||||
DownloadErrorType.rateLimit: 'rateLimit',
|
||||
DownloadErrorType.network: 'network',
|
||||
DownloadErrorType.permission: 'permission',
|
||||
DownloadErrorType.verificationRequired: 'verificationRequired',
|
||||
};
|
||||
|
||||
+28
-20
@@ -15,14 +15,13 @@ class AppSettings {
|
||||
final String storageMode; // 'app' or 'saf'
|
||||
final String downloadTreeUri; // SAF persistable tree URI
|
||||
final bool autoFallback;
|
||||
final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding
|
||||
final bool embedMetadata;
|
||||
final String
|
||||
artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats
|
||||
final bool embedLyrics;
|
||||
final bool embedReplayGain; // Calculate and embed ReplayGain tags
|
||||
final bool embedReplayGain;
|
||||
final bool maxQualityCover;
|
||||
final bool isFirstLaunch;
|
||||
final int concurrentDownloads;
|
||||
final bool checkForUpdates;
|
||||
final String updateChannel;
|
||||
final bool hasSearchedBefore;
|
||||
@@ -44,37 +43,37 @@ class AppSettings {
|
||||
final String singleFilenameFormat;
|
||||
final String albumFolderStructure;
|
||||
final bool showExtensionStore;
|
||||
final String
|
||||
extensionVerificationBrowserMode; // 'external_first' or 'in_app_first'
|
||||
final String locale;
|
||||
final String lyricsMode;
|
||||
final String
|
||||
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
|
||||
tidalHighFormat; // Legacy key for 320kbps lossy output format: 'mp3_320', 'aac_320', 'opus_256', or 'opus_128'
|
||||
final bool
|
||||
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
|
||||
final bool
|
||||
autoExportFailedDownloads; // Auto export failed downloads to TXT file
|
||||
final bool autoExportFailedDownloads;
|
||||
final String
|
||||
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
|
||||
final bool
|
||||
networkCompatibilityMode; // Try HTTP + allow invalid TLS cert for API requests
|
||||
final bool
|
||||
allowLocalNetwork; // Allow requests to private/local network targets (local proxy / custom DNS)
|
||||
final String
|
||||
songLinkRegion; // SongLink userCountry region code used for platform lookup
|
||||
final bool
|
||||
nativeDownloadWorkerEnabled; // Experimental Android service-owned worker
|
||||
|
||||
final bool localLibraryEnabled; // Enable local library scanning
|
||||
final String localLibraryPath; // Path to scan for audio files
|
||||
final bool localLibraryEnabled;
|
||||
final String localLibraryPath;
|
||||
final String
|
||||
localLibraryBookmark; // Base64-encoded iOS security-scoped bookmark
|
||||
final bool
|
||||
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
|
||||
final bool localLibraryShowDuplicates;
|
||||
final String
|
||||
localLibraryAutoScan; // Auto-scan mode: 'off', 'on_open', 'daily', 'weekly'
|
||||
|
||||
final bool
|
||||
hasCompletedTutorial; // Track if user has completed the app tutorial
|
||||
final bool hasCompletedTutorial;
|
||||
|
||||
final List<String>
|
||||
lyricsProviders; // Ordered list of enabled lyrics provider IDs
|
||||
final List<String> lyricsProviders;
|
||||
final bool
|
||||
lyricsIncludeTranslationNetease; // Append translated lyrics (Netease)
|
||||
final bool
|
||||
@@ -89,9 +88,10 @@ class AppSettings {
|
||||
final String
|
||||
lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0')
|
||||
|
||||
final bool
|
||||
deduplicateDownloads; // Skip downloading tracks already present in history
|
||||
final bool saveDownloadHistory; // Record completed downloads in local history
|
||||
final bool deduplicateDownloads;
|
||||
final bool saveDownloadHistory;
|
||||
|
||||
final String playerMode;
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = '',
|
||||
@@ -108,7 +108,6 @@ class AppSettings {
|
||||
this.embedReplayGain = false,
|
||||
this.maxQualityCover = true,
|
||||
this.isFirstLaunch = true,
|
||||
this.concurrentDownloads = 1,
|
||||
this.checkForUpdates = true,
|
||||
this.updateChannel = 'stable',
|
||||
this.hasSearchedBefore = false,
|
||||
@@ -130,6 +129,7 @@ class AppSettings {
|
||||
this.singleFilenameFormat = '{title} - {artist}',
|
||||
this.albumFolderStructure = 'artist_album',
|
||||
this.showExtensionStore = true,
|
||||
this.extensionVerificationBrowserMode = 'in_app_first',
|
||||
this.locale = 'system',
|
||||
this.lyricsMode = 'embed',
|
||||
this.tidalHighFormat = 'mp3_320',
|
||||
@@ -137,6 +137,7 @@ class AppSettings {
|
||||
this.autoExportFailedDownloads = false,
|
||||
this.downloadNetworkMode = 'any',
|
||||
this.networkCompatibilityMode = false,
|
||||
this.allowLocalNetwork = false,
|
||||
this.songLinkRegion = 'US',
|
||||
this.nativeDownloadWorkerEnabled = false,
|
||||
this.localLibraryEnabled = false,
|
||||
@@ -154,6 +155,7 @@ class AppSettings {
|
||||
this.lastSeenVersion = '',
|
||||
this.deduplicateDownloads = true,
|
||||
this.saveDownloadHistory = true,
|
||||
this.playerMode = 'external',
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -171,7 +173,6 @@ class AppSettings {
|
||||
bool? embedReplayGain,
|
||||
bool? maxQualityCover,
|
||||
bool? isFirstLaunch,
|
||||
int? concurrentDownloads,
|
||||
bool? checkForUpdates,
|
||||
String? updateChannel,
|
||||
bool? hasSearchedBefore,
|
||||
@@ -196,6 +197,7 @@ class AppSettings {
|
||||
String? singleFilenameFormat,
|
||||
String? albumFolderStructure,
|
||||
bool? showExtensionStore,
|
||||
String? extensionVerificationBrowserMode,
|
||||
String? locale,
|
||||
String? lyricsMode,
|
||||
String? tidalHighFormat,
|
||||
@@ -203,6 +205,7 @@ class AppSettings {
|
||||
bool? autoExportFailedDownloads,
|
||||
String? downloadNetworkMode,
|
||||
bool? networkCompatibilityMode,
|
||||
bool? allowLocalNetwork,
|
||||
String? songLinkRegion,
|
||||
bool? nativeDownloadWorkerEnabled,
|
||||
bool? localLibraryEnabled,
|
||||
@@ -220,6 +223,7 @@ class AppSettings {
|
||||
String? lastSeenVersion,
|
||||
bool? deduplicateDownloads,
|
||||
bool? saveDownloadHistory,
|
||||
String? playerMode,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -237,7 +241,6 @@ class AppSettings {
|
||||
embedReplayGain: embedReplayGain ?? this.embedReplayGain,
|
||||
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
||||
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
|
||||
updateChannel: updateChannel ?? this.updateChannel,
|
||||
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
|
||||
@@ -270,6 +273,9 @@ class AppSettings {
|
||||
singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat,
|
||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||
extensionVerificationBrowserMode:
|
||||
extensionVerificationBrowserMode ??
|
||||
this.extensionVerificationBrowserMode,
|
||||
locale: locale ?? this.locale,
|
||||
lyricsMode: lyricsMode ?? this.lyricsMode,
|
||||
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
|
||||
@@ -279,6 +285,7 @@ class AppSettings {
|
||||
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
|
||||
networkCompatibilityMode:
|
||||
networkCompatibilityMode ?? this.networkCompatibilityMode,
|
||||
allowLocalNetwork: allowLocalNetwork ?? this.allowLocalNetwork,
|
||||
songLinkRegion: songLinkRegion ?? this.songLinkRegion,
|
||||
nativeDownloadWorkerEnabled:
|
||||
nativeDownloadWorkerEnabled ?? this.nativeDownloadWorkerEnabled,
|
||||
@@ -304,6 +311,7 @@ class AppSettings {
|
||||
lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion,
|
||||
deduplicateDownloads: deduplicateDownloads ?? this.deduplicateDownloads,
|
||||
saveDownloadHistory: saveDownloadHistory ?? this.saveDownloadHistory,
|
||||
playerMode: playerMode ?? this.playerMode,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
embedReplayGain: json['embedReplayGain'] as bool? ?? false,
|
||||
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
||||
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||
updateChannel: json['updateChannel'] as String? ?? 'stable',
|
||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||
@@ -49,6 +48,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
albumFolderStructure:
|
||||
json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||
extensionVerificationBrowserMode:
|
||||
json['extensionVerificationBrowserMode'] as String? ?? 'in_app_first',
|
||||
locale: json['locale'] as String? ?? 'system',
|
||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
||||
@@ -57,6 +58,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
json['autoExportFailedDownloads'] as bool? ?? false,
|
||||
downloadNetworkMode: json['downloadNetworkMode'] as String? ?? 'any',
|
||||
networkCompatibilityMode: json['networkCompatibilityMode'] as bool? ?? false,
|
||||
allowLocalNetwork: json['allowLocalNetwork'] as bool? ?? false,
|
||||
songLinkRegion: json['songLinkRegion'] as String? ?? 'US',
|
||||
nativeDownloadWorkerEnabled:
|
||||
json['nativeDownloadWorkerEnabled'] as bool? ?? false,
|
||||
@@ -83,6 +85,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
lastSeenVersion: json['lastSeenVersion'] as String? ?? '',
|
||||
deduplicateDownloads: json['deduplicateDownloads'] as bool? ?? true,
|
||||
saveDownloadHistory: json['saveDownloadHistory'] as bool? ?? true,
|
||||
playerMode: json['playerMode'] as String? ?? 'external',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(
|
||||
@@ -102,7 +105,6 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'embedReplayGain': instance.embedReplayGain,
|
||||
'maxQualityCover': instance.maxQualityCover,
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
'concurrentDownloads': instance.concurrentDownloads,
|
||||
'checkForUpdates': instance.checkForUpdates,
|
||||
'updateChannel': instance.updateChannel,
|
||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||
@@ -125,6 +127,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'singleFilenameFormat': instance.singleFilenameFormat,
|
||||
'albumFolderStructure': instance.albumFolderStructure,
|
||||
'showExtensionStore': instance.showExtensionStore,
|
||||
'extensionVerificationBrowserMode': instance.extensionVerificationBrowserMode,
|
||||
'locale': instance.locale,
|
||||
'lyricsMode': instance.lyricsMode,
|
||||
'tidalHighFormat': instance.tidalHighFormat,
|
||||
@@ -132,6 +135,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
||||
'downloadNetworkMode': instance.downloadNetworkMode,
|
||||
'networkCompatibilityMode': instance.networkCompatibilityMode,
|
||||
'allowLocalNetwork': instance.allowLocalNetwork,
|
||||
'songLinkRegion': instance.songLinkRegion,
|
||||
'nativeDownloadWorkerEnabled': instance.nativeDownloadWorkerEnabled,
|
||||
'localLibraryEnabled': instance.localLibraryEnabled,
|
||||
@@ -149,4 +153,5 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'lastSeenVersion': instance.lastSeenVersion,
|
||||
'deduplicateDownloads': instance.deduplicateDownloads,
|
||||
'saveDownloadHistory': instance.saveDownloadHistory,
|
||||
'playerMode': instance.playerMode,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ class Track {
|
||||
final String? albumId;
|
||||
final String? coverUrl;
|
||||
final String? isrc;
|
||||
final String? previewUrl;
|
||||
final int duration;
|
||||
final int? trackNumber;
|
||||
final int? discNumber;
|
||||
@@ -38,6 +39,7 @@ class Track {
|
||||
this.albumId,
|
||||
this.coverUrl,
|
||||
this.isrc,
|
||||
this.previewUrl,
|
||||
required this.duration,
|
||||
this.trackNumber,
|
||||
this.discNumber,
|
||||
@@ -81,6 +83,8 @@ class Track {
|
||||
audioModes != null && audioModes!.contains('DOLBY_ATMOS');
|
||||
|
||||
bool get hasAudioQuality => audioQuality != null && audioQuality!.isNotEmpty;
|
||||
|
||||
bool get hasPreview => previewUrl != null && previewUrl!.isNotEmpty;
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
|
||||
@@ -16,6 +16,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||
albumId: json['albumId'] as String?,
|
||||
coverUrl: json['coverUrl'] as String?,
|
||||
isrc: json['isrc'] as String?,
|
||||
previewUrl: json['previewUrl'] as String?,
|
||||
duration: (json['duration'] as num).toInt(),
|
||||
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
||||
discNumber: (json['discNumber'] as num?)?.toInt(),
|
||||
@@ -46,6 +47,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
'albumId': instance.albumId,
|
||||
'coverUrl': instance.coverUrl,
|
||||
'isrc': instance.isrc,
|
||||
'previewUrl': instance.previewUrl,
|
||||
'duration': instance.duration,
|
||||
'trackNumber': instance.trackNumber,
|
||||
'discNumber': instance.discNumber,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,22 @@ final _log = AppLogger('ExtensionProvider');
|
||||
const _metadataProviderPriorityKey = 'metadata_provider_priority';
|
||||
const _providerPriorityKey = 'provider_priority';
|
||||
const _spotifyWebExtensionId = 'spotify-web';
|
||||
const _storeRegistryUrlPrefKey = 'store_registry_url';
|
||||
|
||||
/// Result of restoring extensions from a backup.
|
||||
class ExtensionRestoreResult {
|
||||
final int installed;
|
||||
final int alreadyPresent;
|
||||
final int failed;
|
||||
final List<String> failedIds;
|
||||
|
||||
const ExtensionRestoreResult({
|
||||
this.installed = 0,
|
||||
this.alreadyPresent = 0,
|
||||
this.failed = 0,
|
||||
this.failedIds = const [],
|
||||
});
|
||||
}
|
||||
|
||||
bool _stringListEquals(List<String> a, List<String> b) {
|
||||
if (identical(a, b)) return true;
|
||||
@@ -792,12 +808,15 @@ class ExtensionInstallBatchResult {
|
||||
}
|
||||
|
||||
class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
static const _extensionHealthCacheTtl = Duration(seconds: 60);
|
||||
static const _extensionHealthDefaultCacheTtl = Duration(minutes: 10);
|
||||
static const _extensionHealthMinimumCacheTtl = Duration(minutes: 1);
|
||||
static const _extensionHealthUnknownCacheTtl = Duration(minutes: 2);
|
||||
AppLifecycleListener? _appLifecycleListener;
|
||||
bool _cleanupInFlight = false;
|
||||
Completer<void>? _initializationCompleter;
|
||||
final Map<String, DateTime> _healthExpiresAt = {};
|
||||
final Map<String, Future<ExtensionHealthStatus?>> _healthInFlight = {};
|
||||
final Map<String, int> _healthRequestSerial = {};
|
||||
|
||||
@override
|
||||
ExtensionState build() {
|
||||
@@ -809,6 +828,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
_appLifecycleListener = null;
|
||||
_healthExpiresAt.clear();
|
||||
_healthInFlight.clear();
|
||||
_healthRequestSerial.clear();
|
||||
});
|
||||
return const ExtensionState();
|
||||
}
|
||||
@@ -938,15 +958,46 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleExtensionHealthRefresh(List<Extension> extensions) {
|
||||
void _scheduleExtensionHealthRefresh(
|
||||
List<Extension> extensions, {
|
||||
bool force = false,
|
||||
}) {
|
||||
for (final ext in extensions) {
|
||||
if (!ext.enabled || !ext.hasServiceHealth) continue;
|
||||
unawaited(checkExtensionHealth(ext.id));
|
||||
unawaited(checkExtensionHealth(ext.id, force: force));
|
||||
}
|
||||
}
|
||||
|
||||
void refreshEnabledExtensionHealth() {
|
||||
_scheduleExtensionHealthRefresh(state.extensions);
|
||||
void refreshEnabledExtensionHealth({bool force = false}) {
|
||||
_scheduleExtensionHealthRefresh(state.extensions, force: force);
|
||||
}
|
||||
|
||||
Duration _extensionHealthCacheTtlFor(Extension extension) {
|
||||
var ttl = _extensionHealthDefaultCacheTtl;
|
||||
for (final check in extension.serviceHealth) {
|
||||
final seconds = check.cacheTtlSeconds;
|
||||
if (seconds == null || seconds <= 0) continue;
|
||||
|
||||
var checkTtl = Duration(seconds: seconds);
|
||||
if (checkTtl < _extensionHealthMinimumCacheTtl) {
|
||||
checkTtl = _extensionHealthMinimumCacheTtl;
|
||||
}
|
||||
if (checkTtl < ttl) {
|
||||
ttl = checkTtl;
|
||||
}
|
||||
}
|
||||
return ttl;
|
||||
}
|
||||
|
||||
Duration _extensionHealthCacheTtlForStatus(
|
||||
Extension extension,
|
||||
String status,
|
||||
) {
|
||||
final ttl = _extensionHealthCacheTtlFor(extension);
|
||||
if (status == 'unknown' && ttl > _extensionHealthUnknownCacheTtl) {
|
||||
return _extensionHealthUnknownCacheTtl;
|
||||
}
|
||||
return ttl;
|
||||
}
|
||||
|
||||
Future<ExtensionHealthStatus?> checkExtensionHealth(
|
||||
@@ -974,17 +1025,22 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
final requestSerial = (_healthRequestSerial[extensionId] ?? 0) + 1;
|
||||
_healthRequestSerial[extensionId] = requestSerial;
|
||||
|
||||
final future = () async {
|
||||
try {
|
||||
final result = await PlatformBridge.checkExtensionHealth(extensionId);
|
||||
final status = ExtensionHealthStatus.fromJson(result);
|
||||
final updated = Map<String, ExtensionHealthStatus>.of(
|
||||
state.healthStatuses,
|
||||
)..[extensionId] = status;
|
||||
_healthExpiresAt[extensionId] = DateTime.now().add(
|
||||
_extensionHealthCacheTtl,
|
||||
);
|
||||
state = state.copyWith(healthStatuses: updated);
|
||||
if (_healthRequestSerial[extensionId] == requestSerial) {
|
||||
final updated = Map<String, ExtensionHealthStatus>.of(
|
||||
state.healthStatuses,
|
||||
)..[extensionId] = status;
|
||||
_healthExpiresAt[extensionId] = DateTime.now().add(
|
||||
_extensionHealthCacheTtlForStatus(ext, status.status),
|
||||
);
|
||||
state = state.copyWith(healthStatuses: updated);
|
||||
}
|
||||
return status;
|
||||
} catch (e) {
|
||||
_log.w('Failed to check extension health for $extensionId: $e');
|
||||
@@ -994,16 +1050,20 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
checkedAt: DateTime.now(),
|
||||
checks: const [],
|
||||
);
|
||||
final updated = Map<String, ExtensionHealthStatus>.of(
|
||||
state.healthStatuses,
|
||||
)..[extensionId] = status;
|
||||
_healthExpiresAt[extensionId] = DateTime.now().add(
|
||||
const Duration(seconds: 20),
|
||||
);
|
||||
state = state.copyWith(healthStatuses: updated);
|
||||
if (_healthRequestSerial[extensionId] == requestSerial) {
|
||||
final updated = Map<String, ExtensionHealthStatus>.of(
|
||||
state.healthStatuses,
|
||||
)..[extensionId] = status;
|
||||
_healthExpiresAt[extensionId] = DateTime.now().add(
|
||||
_extensionHealthUnknownCacheTtl,
|
||||
);
|
||||
state = state.copyWith(healthStatuses: updated);
|
||||
}
|
||||
return status;
|
||||
} finally {
|
||||
_healthInFlight.remove(extensionId);
|
||||
if (_healthRequestSerial[extensionId] == requestSerial) {
|
||||
_healthInFlight.remove(extensionId);
|
||||
}
|
||||
}
|
||||
}();
|
||||
|
||||
@@ -1283,20 +1343,20 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
bool downloadProviderMatchesBuiltIn(
|
||||
bool downloadProviderReplacesLegacyProvider(
|
||||
String providerId,
|
||||
String builtInProviderId,
|
||||
String legacyProviderId,
|
||||
) {
|
||||
final normalizedProvider = providerId.trim().toLowerCase();
|
||||
final normalizedBuiltIn = builtInProviderId.trim().toLowerCase();
|
||||
if (normalizedProvider.isEmpty || normalizedBuiltIn.isEmpty) return false;
|
||||
if (normalizedProvider == normalizedBuiltIn) return true;
|
||||
final normalizedLegacy = legacyProviderId.trim().toLowerCase();
|
||||
if (normalizedProvider.isEmpty || normalizedLegacy.isEmpty) return false;
|
||||
if (normalizedProvider == normalizedLegacy) return true;
|
||||
|
||||
final extension = state.extensions
|
||||
.where((ext) => ext.enabled && ext.hasDownloadProvider)
|
||||
.where((ext) => ext.id.toLowerCase() == normalizedProvider)
|
||||
.firstOrNull;
|
||||
return extension?.replacesBuiltInProviders.contains(normalizedBuiltIn) ??
|
||||
return extension?.replacesBuiltInProviders.contains(normalizedLegacy) ??
|
||||
false;
|
||||
}
|
||||
|
||||
@@ -1640,7 +1700,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
List<Extension> get enabledExtensions {
|
||||
List<Extension> enabledExtensions() {
|
||||
return state.extensions.where((ext) => ext.enabled).toList();
|
||||
}
|
||||
|
||||
@@ -1717,11 +1777,208 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
return result;
|
||||
}
|
||||
|
||||
List<Extension> get searchProviders {
|
||||
List<Extension> searchProviders() {
|
||||
return state.extensions
|
||||
.where((ext) => ext.enabled && ext.hasCustomSearch)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Collects the keys flagged as `secret` in an extension's manifest schema
|
||||
/// (top-level settings and quality-specific settings).
|
||||
Set<String> _secretKeysFromManifest(Map<String, dynamic> raw) {
|
||||
final keys = <String>{};
|
||||
|
||||
void scan(Object? settingsList) {
|
||||
if (settingsList is! List) return;
|
||||
for (final entry in settingsList) {
|
||||
if (entry is Map && entry['secret'] == true && entry['key'] is String) {
|
||||
keys.add(entry['key'] as String);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scan(raw['settings']);
|
||||
final quality = raw['quality_options'];
|
||||
if (quality is List) {
|
||||
for (final option in quality) {
|
||||
if (option is Map) {
|
||||
scan(option['settings']);
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/// Builds the extensions section of a backup: the store registry URL plus the
|
||||
/// installed extensions with their id, version, enabled flag and settings.
|
||||
/// Secret-flagged settings (tokens, API keys) are only included when
|
||||
/// [includeSecrets] is true.
|
||||
Future<Map<String, dynamic>> exportBackup({
|
||||
required bool includeSecrets,
|
||||
}) async {
|
||||
if (!PlatformBridge.supportsExtensionSystem) {
|
||||
return {'registry_url': '', 'items': const <Map<String, dynamic>>[]};
|
||||
}
|
||||
|
||||
String registryUrl = '';
|
||||
try {
|
||||
registryUrl = await PlatformBridge.getStoreRegistryUrl();
|
||||
} catch (_) {}
|
||||
|
||||
List<Map<String, dynamic>> installed;
|
||||
try {
|
||||
installed = await PlatformBridge.getInstalledExtensions();
|
||||
} catch (e) {
|
||||
_log.w('Backup: failed to list extensions: $e');
|
||||
installed = const [];
|
||||
}
|
||||
|
||||
final items = <Map<String, dynamic>>[];
|
||||
for (final raw in installed) {
|
||||
final id = raw['id'] as String?;
|
||||
if (id == null || id.isEmpty) continue;
|
||||
final secretKeys = _secretKeysFromManifest(raw);
|
||||
|
||||
Map<String, dynamic> settings = {};
|
||||
try {
|
||||
settings = await PlatformBridge.getExtensionSettings(id);
|
||||
} catch (_) {}
|
||||
|
||||
final filtered = <String, dynamic>{};
|
||||
var omittedSecret = false;
|
||||
settings.forEach((key, value) {
|
||||
if (secretKeys.contains(key)) {
|
||||
if (!includeSecrets) {
|
||||
omittedSecret = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
filtered[key] = value;
|
||||
});
|
||||
|
||||
items.add({
|
||||
'id': id,
|
||||
'version': raw['version']?.toString() ?? '',
|
||||
'enabled': raw['enabled'] == true,
|
||||
'settings': filtered,
|
||||
if (omittedSecret) 'secrets_omitted': true,
|
||||
});
|
||||
}
|
||||
|
||||
return {'registry_url': registryUrl, 'items': items};
|
||||
}
|
||||
|
||||
/// Restores extensions from a backup section produced by [exportBackup]:
|
||||
/// re-applies the store registry URL, reinstalls each extension from the
|
||||
/// store when missing, then merges settings and restores the enabled flag.
|
||||
/// Missing settings (e.g. omitted secrets) are merged with the current values
|
||||
/// so they are not wiped.
|
||||
Future<ExtensionRestoreResult> restoreFromBackup(
|
||||
Map<String, dynamic> data,
|
||||
) async {
|
||||
if (!PlatformBridge.supportsExtensionSystem) {
|
||||
return const ExtensionRestoreResult();
|
||||
}
|
||||
|
||||
final registryUrl = (data['registry_url'] as String?)?.trim() ?? '';
|
||||
final itemsRaw = data['items'];
|
||||
final items = itemsRaw is List
|
||||
? itemsRaw
|
||||
.whereType<Map<Object?, Object?>>()
|
||||
.map((e) => Map<String, dynamic>.from(e))
|
||||
.toList()
|
||||
: <Map<String, dynamic>>[];
|
||||
|
||||
Directory? destDir;
|
||||
try {
|
||||
final tmp = await getTemporaryDirectory();
|
||||
destDir = await Directory(
|
||||
'${tmp.path}/spotiflac_restore_ext',
|
||||
).create(recursive: true);
|
||||
await PlatformBridge.initExtensionStore(destDir.path);
|
||||
if (registryUrl.isNotEmpty) {
|
||||
await PlatformBridge.setStoreRegistryUrl(registryUrl);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_storeRegistryUrlPrefKey, registryUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Restore: failed to prepare extension store: $e');
|
||||
}
|
||||
|
||||
await refreshExtensions();
|
||||
final installedIds = state.extensions
|
||||
.map((e) => e.id.toLowerCase())
|
||||
.toSet();
|
||||
|
||||
var installedCount = 0;
|
||||
var alreadyPresent = 0;
|
||||
var failed = 0;
|
||||
final failedIds = <String>[];
|
||||
|
||||
for (final item in items) {
|
||||
final id = item['id'] as String?;
|
||||
if (id == null || id.isEmpty) continue;
|
||||
final enabled = item['enabled'] != false;
|
||||
var present = installedIds.contains(id.toLowerCase());
|
||||
|
||||
if (!present) {
|
||||
if (destDir == null) {
|
||||
failed++;
|
||||
failedIds.add(id);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
final path = await PlatformBridge.downloadStoreExtension(
|
||||
id,
|
||||
destDir.path,
|
||||
);
|
||||
final ok = await installExtension(path);
|
||||
if (ok) {
|
||||
installedCount++;
|
||||
present = true;
|
||||
} else {
|
||||
failed++;
|
||||
failedIds.add(id);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Restore: failed to install extension $id: $e');
|
||||
failed++;
|
||||
failedIds.add(id);
|
||||
}
|
||||
} else {
|
||||
alreadyPresent++;
|
||||
}
|
||||
|
||||
if (!present) continue;
|
||||
|
||||
final settings = item['settings'];
|
||||
if (settings is Map && settings.isNotEmpty) {
|
||||
try {
|
||||
final current = await PlatformBridge.getExtensionSettings(id);
|
||||
final merged = <String, dynamic>{
|
||||
...current,
|
||||
...Map<String, dynamic>.from(settings),
|
||||
};
|
||||
await PlatformBridge.setExtensionSettings(id, merged);
|
||||
} catch (e) {
|
||||
_log.w('Restore: failed to apply settings for $id: $e');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await setExtensionEnabled(id, enabled);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
await refreshExtensions();
|
||||
|
||||
return ExtensionRestoreResult(
|
||||
installed: installedCount,
|
||||
alreadyPresent: alreadyPresent,
|
||||
failed: failed,
|
||||
failedIds: failedIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final extensionProvider = NotifierProvider<ExtensionNotifier, ExtensionState>(
|
||||
|
||||
@@ -953,6 +953,90 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
});
|
||||
_invalidatePlaylistPickerSummaries();
|
||||
}
|
||||
|
||||
/// Returns the full collections snapshot (wishlist, loved, playlists,
|
||||
/// favorite artists) for a backup, ensuring data is loaded first.
|
||||
Future<Map<String, dynamic>> exportCollections() async {
|
||||
await _ensureLoaded();
|
||||
return state.toJson();
|
||||
}
|
||||
|
||||
/// Exports custom playlist cover images as base64, keyed by playlist id.
|
||||
/// Each value contains the original file extension and the encoded bytes so a
|
||||
/// restore on another device can recreate the cover files.
|
||||
Future<Map<String, Map<String, String>>> exportPlaylistCovers() async {
|
||||
await _ensureLoaded();
|
||||
final covers = <String, Map<String, String>>{};
|
||||
for (final playlist in state.playlists) {
|
||||
final path = playlist.coverImagePath;
|
||||
if (path == null || path.isEmpty) continue;
|
||||
try {
|
||||
final file = File(path);
|
||||
if (!await file.exists()) continue;
|
||||
final bytes = await file.readAsBytes();
|
||||
if (bytes.isEmpty) continue;
|
||||
covers[playlist.id] = {
|
||||
'ext': p.extension(path).toLowerCase(),
|
||||
'data': base64Encode(bytes),
|
||||
};
|
||||
} catch (_) {
|
||||
// Skip unreadable cover; the rest of the backup still succeeds.
|
||||
}
|
||||
}
|
||||
return covers;
|
||||
}
|
||||
|
||||
/// Replaces all collections (wishlist, loved, playlists, favorite artists)
|
||||
/// with the contents of a backup. [collectionsJson] uses the
|
||||
/// [LibraryCollectionsState.toJson] shape; [coverImages] is the map produced
|
||||
/// by [exportPlaylistCovers]. Cover images are rewritten into this device's
|
||||
/// covers directory and their paths fixed up before persisting.
|
||||
Future<void> restoreFromBackup(
|
||||
Map<String, dynamic> collectionsJson, {
|
||||
Map<String, dynamic>? coverImages,
|
||||
}) async {
|
||||
final normalized = Map<String, dynamic>.from(collectionsJson);
|
||||
final coversDir = await _playlistCoversDir();
|
||||
|
||||
final playlistsRaw = normalized['playlists'];
|
||||
if (playlistsRaw is List) {
|
||||
final rewritten = <Map<String, dynamic>>[];
|
||||
for (final entry in playlistsRaw.whereType<Map<Object?, Object?>>()) {
|
||||
final playlist = Map<String, dynamic>.from(entry);
|
||||
final id = playlist['id'] as String?;
|
||||
String? newCoverPath;
|
||||
final coverEntry = (id != null && coverImages != null)
|
||||
? coverImages[id]
|
||||
: null;
|
||||
if (id != null && coverEntry is Map) {
|
||||
final data = coverEntry['data'] as String?;
|
||||
final ext = (coverEntry['ext'] as String?) ?? '.jpg';
|
||||
if (data != null && data.isNotEmpty) {
|
||||
try {
|
||||
final destPath = p.join(coversDir.path, '$id$ext');
|
||||
await File(destPath).writeAsBytes(base64Decode(data));
|
||||
newCoverPath = destPath;
|
||||
} catch (_) {
|
||||
newCoverPath = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Always replace the backup's device-specific path: either with the
|
||||
// freshly written local cover, or drop it so a stale path is not kept.
|
||||
if (newCoverPath != null) {
|
||||
playlist['coverImagePath'] = newCoverPath;
|
||||
} else {
|
||||
playlist.remove('coverImagePath');
|
||||
}
|
||||
rewritten.add(playlist);
|
||||
}
|
||||
normalized['playlists'] = rewritten;
|
||||
}
|
||||
|
||||
await _db.replaceAllFromBackup(normalized);
|
||||
await _load();
|
||||
_invalidatePlaylistPickerSummaries();
|
||||
}
|
||||
}
|
||||
|
||||
final libraryCollectionsProvider =
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/music_player_service.dart';
|
||||
|
||||
final currentMediaItemProvider = StreamProvider<MediaItem?>((ref) {
|
||||
return musicPlayerMediaItemEvents();
|
||||
});
|
||||
|
||||
final playbackStateProvider = StreamProvider<PlaybackState>((ref) {
|
||||
return musicPlayerPlaybackStateEvents();
|
||||
});
|
||||
|
||||
final playQueueProvider = StreamProvider<List<MediaItem>>((ref) {
|
||||
return musicPlayerQueueEvents();
|
||||
});
|
||||
|
||||
class MusicPlayerController {
|
||||
const MusicPlayerController();
|
||||
|
||||
MusicPlayerHandler? get _handler => musicPlayerHandler;
|
||||
|
||||
bool get isAvailable => _handler != null;
|
||||
|
||||
Future<MusicPlayerHandler?> ensureInitialized() async {
|
||||
try {
|
||||
return await initMusicPlayer();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playAll(
|
||||
List<PlayableMedia> items, {
|
||||
int initialIndex = 0,
|
||||
}) async {
|
||||
final handler = await ensureInitialized();
|
||||
await handler?.setQueueAndPlay(items, initialIndex: initialIndex);
|
||||
}
|
||||
|
||||
Future<void> playSingle(PlayableMedia item) => playAll([item]);
|
||||
|
||||
Future<void> playHistory(
|
||||
List<DownloadHistoryItem> items, {
|
||||
int initialIndex = 0,
|
||||
}) async {
|
||||
final media = items
|
||||
.where((i) => i.filePath.trim().isNotEmpty)
|
||||
.map(playableFromHistory)
|
||||
.toList();
|
||||
if (media.isEmpty) return;
|
||||
await playAll(media, initialIndex: initialIndex.clamp(0, media.length - 1));
|
||||
}
|
||||
|
||||
Future<void> playLocal(
|
||||
List<LocalLibraryItem> items, {
|
||||
int initialIndex = 0,
|
||||
}) async {
|
||||
final media = items
|
||||
.where((i) => i.filePath.trim().isNotEmpty)
|
||||
.map(playableFromLocal)
|
||||
.toList();
|
||||
if (media.isEmpty) return;
|
||||
await playAll(media, initialIndex: initialIndex.clamp(0, media.length - 1));
|
||||
}
|
||||
|
||||
Future<void> play() async => _handler?.play();
|
||||
Future<void> pause() async => _handler?.pause();
|
||||
Future<void> stop() async => _handler?.stop();
|
||||
Future<void> seek(Duration position) async => _handler?.seek(position);
|
||||
Future<void> next() async => _handler?.skipToNext();
|
||||
Future<void> previous() async => _handler?.skipToPrevious();
|
||||
|
||||
Future<void> togglePlayPause(bool isPlaying) async {
|
||||
if (isPlaying) {
|
||||
await pause();
|
||||
} else {
|
||||
await play();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setShuffle(bool enabled) async {
|
||||
await _handler?.setShuffleMode(
|
||||
enabled ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> playNext(PlayableMedia item) async =>
|
||||
(await ensureInitialized())?.enqueue(item, playNext: true);
|
||||
|
||||
Future<void> addToQueue(PlayableMedia item) async =>
|
||||
(await ensureInitialized())?.enqueue(item);
|
||||
|
||||
Future<void> playNextHistory(DownloadHistoryItem item) async =>
|
||||
playNext(playableFromHistory(item));
|
||||
|
||||
Future<void> addToQueueHistory(DownloadHistoryItem item) async =>
|
||||
addToQueue(playableFromHistory(item));
|
||||
|
||||
Future<void> playNextLocal(LocalLibraryItem item) async =>
|
||||
playNext(playableFromLocal(item));
|
||||
|
||||
Future<void> addToQueueLocal(LocalLibraryItem item) async =>
|
||||
addToQueue(playableFromLocal(item));
|
||||
|
||||
Future<void> jumpTo(int index) async => _handler?.skipToQueueItem(index);
|
||||
|
||||
void moveQueueItem(int oldIndex, int newIndex) {
|
||||
_handler?.moveQueueItem(oldIndex, newIndex);
|
||||
}
|
||||
}
|
||||
|
||||
final musicPlayerControllerProvider = Provider<MusicPlayerController>(
|
||||
(ref) => const MusicPlayerController(),
|
||||
);
|
||||
|
||||
PlayableMedia playableFromHistory(DownloadHistoryItem item) {
|
||||
return PlayableMedia(
|
||||
id: item.id,
|
||||
source: item.filePath,
|
||||
title: item.trackName,
|
||||
artist: item.artistName,
|
||||
album: item.albumName,
|
||||
artUri: (item.coverUrl != null && item.coverUrl!.trim().isNotEmpty)
|
||||
? item.coverUrl
|
||||
: null,
|
||||
duration: (item.duration != null && item.duration! > 0)
|
||||
? Duration(seconds: item.duration!)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
PlayableMedia playableFromLocal(LocalLibraryItem item) {
|
||||
String? art;
|
||||
final cover = item.coverPath;
|
||||
if (cover != null && cover.trim().isNotEmpty) {
|
||||
art = cover.startsWith('http') || cover.startsWith('content://')
|
||||
? cover
|
||||
: Uri.file(cover).toString();
|
||||
}
|
||||
return PlayableMedia(
|
||||
id: item.id,
|
||||
source: item.filePath,
|
||||
title: item.trackName,
|
||||
artist: item.artistName,
|
||||
album: item.albumName,
|
||||
artUri: art,
|
||||
duration: (item.duration != null && item.duration! > 0)
|
||||
? Duration(seconds: item.duration!)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/music_player_service.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
@@ -16,6 +19,24 @@ class PlaybackController extends Notifier<PlaybackState> {
|
||||
@override
|
||||
PlaybackState build() => const PlaybackState();
|
||||
|
||||
Future<bool> _useInternalPlayer() async {
|
||||
final mode = ref.read(settingsProvider).playerMode;
|
||||
if (mode != 'internal') return false;
|
||||
return await ref.read(musicPlayerControllerProvider).ensureInitialized() !=
|
||||
null;
|
||||
}
|
||||
|
||||
String? _normalizeArtUri(String cover) {
|
||||
final value = cover.trim();
|
||||
if (value.isEmpty) return null;
|
||||
if (value.startsWith('http') ||
|
||||
value.startsWith('content://') ||
|
||||
value.startsWith('file://')) {
|
||||
return value;
|
||||
}
|
||||
return Uri.file(value).toString();
|
||||
}
|
||||
|
||||
Future<void> playLocalPath({
|
||||
required String path,
|
||||
required String title,
|
||||
@@ -27,14 +48,143 @@ class PlaybackController extends Notifier<PlaybackState> {
|
||||
if (isCueVirtualPath(path)) {
|
||||
throw Exception(cueVirtualTrackRequiresSplitMessage);
|
||||
}
|
||||
|
||||
if (await _useInternalPlayer()) {
|
||||
_log.d('Playing "$title" in the internal player: $path');
|
||||
await ref
|
||||
.read(musicPlayerControllerProvider)
|
||||
.playSingle(
|
||||
PlayableMedia(
|
||||
id: path,
|
||||
source: path,
|
||||
title: title,
|
||||
artist: artist,
|
||||
album: album,
|
||||
artUri: _normalizeArtUri(coverUrl),
|
||||
duration: (track != null && track.duration > 0)
|
||||
? Duration(seconds: track.duration)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_log.d('Opening external player for "$title" by $artist: $path');
|
||||
await openFile(path);
|
||||
}
|
||||
|
||||
/// Plays a local-library album/list starting at [startItem], queuing the rest
|
||||
/// so playback continues to the next track automatically. Honors player mode.
|
||||
Future<void> playLocalLibraryQueue(
|
||||
List<LocalLibraryItem> items, {
|
||||
required LocalLibraryItem startItem,
|
||||
}) async {
|
||||
final playable = items
|
||||
.where(
|
||||
(i) => i.filePath.trim().isNotEmpty && !isCueVirtualPath(i.filePath),
|
||||
)
|
||||
.toList();
|
||||
if (playable.isEmpty) return;
|
||||
var startIndex = playable.indexWhere((i) => i.id == startItem.id);
|
||||
if (startIndex < 0) startIndex = 0;
|
||||
|
||||
if (await _useInternalPlayer()) {
|
||||
await ref
|
||||
.read(musicPlayerControllerProvider)
|
||||
.playLocal(playable, initialIndex: startIndex);
|
||||
} else {
|
||||
await openFile(playable[startIndex].filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays a downloaded-history album/list starting at [startItem], queuing the
|
||||
/// rest. Honors player mode.
|
||||
Future<void> playHistoryQueue(
|
||||
List<DownloadHistoryItem> items, {
|
||||
required DownloadHistoryItem startItem,
|
||||
}) async {
|
||||
final playable = items
|
||||
.where(
|
||||
(i) => i.filePath.trim().isNotEmpty && !isCueVirtualPath(i.filePath),
|
||||
)
|
||||
.toList();
|
||||
if (playable.isEmpty) return;
|
||||
var startIndex = playable.indexWhere((i) => i.id == startItem.id);
|
||||
if (startIndex < 0) startIndex = 0;
|
||||
|
||||
if (await _useInternalPlayer()) {
|
||||
await ref
|
||||
.read(musicPlayerControllerProvider)
|
||||
.playHistory(playable, initialIndex: startIndex);
|
||||
} else {
|
||||
await openFile(playable[startIndex].filePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays a prebuilt media queue starting at [startIndex]. Honors player mode
|
||||
/// ([externalPath] is opened externally when the built-in player is off).
|
||||
Future<void> playMediaQueue(
|
||||
Iterable<PlayableMedia> queue, {
|
||||
required int startIndex,
|
||||
required String externalPath,
|
||||
}) async {
|
||||
if (await _useInternalPlayer()) {
|
||||
final items = queue.toList(growable: false);
|
||||
if (items.isEmpty) return;
|
||||
final i = startIndex.clamp(0, items.length - 1);
|
||||
await ref
|
||||
.read(musicPlayerControllerProvider)
|
||||
.playAll(items, initialIndex: i);
|
||||
} else {
|
||||
await openFile(externalPath);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> playTrackList(List<Track> tracks, {int startIndex = 0}) async {
|
||||
if (tracks.isEmpty) return;
|
||||
|
||||
final orderedTracks = _orderedTracksFromStartIndex(tracks, startIndex);
|
||||
|
||||
if (await _useInternalPlayer()) {
|
||||
final queue = <PlayableMedia>[];
|
||||
var skippedCueVirtualTrack = false;
|
||||
final resolvedPaths = await _resolveTrackPaths(orderedTracks);
|
||||
for (var index = 0; index < orderedTracks.length; index++) {
|
||||
final track = orderedTracks[index];
|
||||
final resolvedPath = resolvedPaths[index];
|
||||
if (resolvedPath == null) continue;
|
||||
if (isCueVirtualPath(resolvedPath)) {
|
||||
skippedCueVirtualTrack = true;
|
||||
continue;
|
||||
}
|
||||
queue.add(
|
||||
PlayableMedia(
|
||||
id: resolvedPath,
|
||||
source: resolvedPath,
|
||||
title: track.name,
|
||||
artist: track.artistName,
|
||||
album: track.albumName,
|
||||
artUri: _normalizeArtUri(track.coverUrl ?? ''),
|
||||
duration: track.duration > 0
|
||||
? Duration(seconds: track.duration)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (queue.isNotEmpty) {
|
||||
_log.d('Playing ${queue.length} tracks in the internal player');
|
||||
await ref.read(musicPlayerControllerProvider).playAll(queue);
|
||||
return;
|
||||
}
|
||||
if (skippedCueVirtualTrack) {
|
||||
throw Exception(cueVirtualTrackRequiresSplitMessage);
|
||||
}
|
||||
throw Exception(
|
||||
'No local audio file is available to play. Download the track first.',
|
||||
);
|
||||
}
|
||||
|
||||
var skippedCueVirtualTrack = false;
|
||||
for (final track in orderedTracks) {
|
||||
final resolvedPath = await _resolveTrackPath(track);
|
||||
@@ -98,6 +248,23 @@ class PlaybackController extends Notifier<PlaybackState> {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<String?>> _resolveTrackPaths(List<Track> tracks) async {
|
||||
if (tracks.isEmpty) return const [];
|
||||
final results = List<String?>.filled(tracks.length, null);
|
||||
var next = 0;
|
||||
final workerCount = tracks.length < 4 ? tracks.length : 4;
|
||||
Future<void> worker() async {
|
||||
while (true) {
|
||||
final index = next++;
|
||||
if (index >= tracks.length) return;
|
||||
results[index] = await _resolveTrackPath(tracks[index]);
|
||||
}
|
||||
}
|
||||
|
||||
await Future.wait(List.generate(workerCount, (_) => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
Future<LocalLibraryItem?> _findLocalLibraryItemForTrack(Track track) async {
|
||||
final isLocalSource = (track.source ?? '').toLowerCase() == 'local';
|
||||
if (isLocalSource) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user