Compare commits
256 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06f2b9ec97 | |||
| 7fee4cea4f | |||
| 526897b23b | |||
| c10c2a290c | |||
| fb5204b0a6 | |||
| 9db4048bc0 | |||
| 63c68b4d4d | |||
| 953ef37882 | |||
| da85a2dcc2 | |||
| 49869792cf | |||
| fb2dda1ed1 | |||
| fad4c4ea36 | |||
| 6b5345a6e5 | |||
| ca413a16fa | |||
| b8b670642c | |||
| 2a2e2924eb | |||
| adea3de737 | |||
| 7d300a39c9 | |||
| 688a5f2add | |||
| d736e5aafe | |||
| 3a536ad348 | |||
| 5dedeb4971 | |||
| 7624e24ea6 | |||
| 7b248d8ab4 | |||
| fdb2009856 | |||
| 8419a75b04 | |||
| 5d474d6fe8 | |||
| e597505a1c | |||
| 8675d263e7 | |||
| 1ce66b9e03 | |||
| cfda124995 | |||
| 212f1cacca | |||
| dd89de7cad | |||
| 8b4372dc7f | |||
| 2a25557632 | |||
| 0cbb339948 | |||
| 1496f51e30 | |||
| d1c5fe0605 | |||
| 56786f60ff | |||
| af5d36f69f | |||
| e40da71ef8 | |||
| 26b8bf422c | |||
| 0a545706bd | |||
| 9ebac610c7 | |||
| 5fc8a6af2a | |||
| 8e68af79aa | |||
| 6246e6e821 | |||
| 421d5ffdc8 | |||
| b82dabe316 | |||
| ffdaf14ba5 | |||
| f52527a41b | |||
| 56a89c5fc6 | |||
| 4f5163be01 | |||
| 822c094c8c | |||
| 1623f443bb | |||
| aa47bc4499 | |||
| f461322842 | |||
| cce05a0077 | |||
| 98dc868f47 | |||
| 821a41c10e | |||
| 853ccd657a | |||
| 680fc81db2 | |||
| 36470eda24 | |||
| a37dd6c8cb | |||
| 588f742871 | |||
| ff25a10e5b | |||
| 499457f66a | |||
| 6d15050009 | |||
| 5ba30031c3 | |||
| 82c0eef504 | |||
| 616267e997 | |||
| 161b0c8c21 | |||
| facd185d6c | |||
| 42858bf336 | |||
| 716be88caf | |||
| b296726a9d | |||
| 092f18d7a5 | |||
| f1ef33e319 | |||
| fc9bc95418 | |||
| c61e64f332 | |||
| 70ebb8ef1a | |||
| a4c6a92478 | |||
| 76b453e535 | |||
| 19acdd87f5 | |||
| 492e1335ef | |||
| 23cde7add3 | |||
| a20c28db25 | |||
| f07d46c49e | |||
| e9781a24a6 | |||
| 15be15ba58 | |||
| 0952b76e11 | |||
| 8011d41e53 | |||
| 5412f23d26 | |||
| 0c39ff47f2 | |||
| 537af905f6 | |||
| 6b4f70bde3 | |||
| be2b6d2c1f | |||
| 0c1a6d8f19 | |||
| 2821997260 | |||
| 0546a33b10 | |||
| deb98d8dfb | |||
| 72c658eda7 | |||
| df17f10c8a | |||
| 9cacf2dc8e | |||
| c7bc9f5b1c | |||
| 49ba8ae0d2 | |||
| 7291dbd9e2 | |||
| fb4cd75cb2 | |||
| 8b7cecc1c5 | |||
| 3a62442ed0 | |||
| 012dcdc2dd | |||
| 3a1b92f9c4 | |||
| 629eb66595 | |||
| 36749a40d3 | |||
| 4336e6dc78 | |||
| 3e3e87e73e | |||
| 1b8d6ce7fa | |||
| 60f1df1488 | |||
| ff86869c33 | |||
| 30f97394ec | |||
| 592308c1c6 | |||
| 2a2d817314 | |||
| 8bcfc63da0 | |||
| a9cfff2692 | |||
| 9e7ff56113 | |||
| 9071143bbd | |||
| 7845ac8be5 | |||
| 40770aff15 | |||
| 81547013f9 | |||
| 8e605cbd0f | |||
| d664d46ca4 | |||
| b4031936a0 | |||
| f84a33bbf2 | |||
| 8f5c59683a | |||
| 4b7146afe4 | |||
| 2bc5ef34ee | |||
| 939407675b | |||
| 6b9a3d95cd | |||
| 20ac6b2cd4 | |||
| 904b45e8f6 | |||
| 1bd54c530b | |||
| 4fe51cef96 | |||
| d005e2e2e7 | |||
| fb5d8826a2 | |||
| 4bc28704ff | |||
| ed7171133f | |||
| 67885e17ed | |||
| fd4da1b7c4 | |||
| 242a57b7eb | |||
| 18467c54d6 | |||
| 8238e2fe68 | |||
| 672ce024f8 | |||
| 8224e93447 | |||
| 1ba810fffb | |||
| 1a725d0d31 | |||
| 51c5b42a78 | |||
| 2908827018 | |||
| b985cbf694 | |||
| 13c2360b7e | |||
| 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 |
@@ -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
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ go_backend/*.xcframework/
|
||||
# Android
|
||||
android/.gradle/
|
||||
android/app/libs/gobackend.aar
|
||||
android/app/libs/gobackend-sources.jar
|
||||
android/local.properties
|
||||
android/*.iml
|
||||
android/key.properties
|
||||
@@ -57,7 +58,6 @@ ios/Pods/
|
||||
ios/.symlinks/
|
||||
ios/Flutter/Flutter.framework/
|
||||
ios/Flutter/Flutter.podspec
|
||||
android/app/libs/gobackend-sources.jar
|
||||
|
||||
# Extension folder
|
||||
extension/
|
||||
@@ -66,8 +66,12 @@ extension/
|
||||
AGENTS.md
|
||||
|
||||
# Temp/misc
|
||||
.tmp/
|
||||
nul
|
||||
NUL
|
||||
network_requests.txt
|
||||
*.bak
|
||||
/AndroidManifest.xml
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
|
||||
@@ -86,7 +86,7 @@ Translation files are located in `lib/l10n/arb/`.
|
||||
git remote add upstream https://github.com/zarzet/SpotiFLAC-Mobile.git
|
||||
```
|
||||
|
||||
3. **Use FVM (Flutter Version: 3.38.1)**
|
||||
3. **Use FVM (Flutter Version: 3.41.5)**
|
||||
```bash
|
||||
fvm use
|
||||
```
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/images/banner-readme-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/images/banner-readme-light.png">
|
||||
<img alt="SpotiFLAC Mobile" src="assets/images/banner-readme-light.png" width="650" height="auto">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/readme/banner-readme-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/readme/banner-readme-light.png">
|
||||
<img alt="SpotiFLAC Mobile" src="assets/readme/banner-readme-light.png" width="650" height="auto">
|
||||
</picture>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/17247">
|
||||
<img src="https://trendshift.io/api/badge/repositories/17247" alt="zarzet%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
||||
<a href="https://trendshift.io/repositories/25971" target="_blank">
|
||||
<img src="https://trendshift.io/api/badge/repositories/25971" alt="spotiflacapp%2FSpotiFLAC-Mobile | Trendshift" width="250" height="55">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -28,10 +28,10 @@
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/images/1.jpg?v=2" width="200" />
|
||||
<img src="assets/images/2.jpg?v=2" width="200" />
|
||||
<img src="assets/images/3.jpg?v=2" width="200" />
|
||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/1.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/2.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/3.jpg?v=2" width="200" />
|
||||
<img src="assets/readme/4.jpg?v=2" width="200" />
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
plugins:
|
||||
riverpod_lint: 3.1.4-dev.3
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
@@ -19,9 +22,6 @@ analyzer:
|
||||
strict-casts: true
|
||||
strict-inference: true
|
||||
strict-raw-types: true
|
||||
plugins:
|
||||
- custom_lint
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
@@ -44,9 +44,5 @@ linter:
|
||||
cancel_subscriptions: true
|
||||
close_sinks: true
|
||||
|
||||
custom_lint:
|
||||
rules:
|
||||
- avoid_public_notifier_properties
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.zarz.spotiflac"
|
||||
compileSdk flutter.compileSdkVersion
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.zarz.spotiflac"
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdk flutter.targetSdkVersion
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
minifyEnabled false
|
||||
shrinkResources false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Go backend library (gomobile generated)
|
||||
implementation fileTree(dir: 'libs', include: ['*.aar'])
|
||||
|
||||
// Kotlin coroutines for async Go backend calls
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
}
|
||||
@@ -120,8 +120,8 @@ dependencies {
|
||||
// Include all AAR and JAR files from libs folder
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0-beta02")
|
||||
implementation("androidx.documentfile:documentfile:1.1.0")
|
||||
implementation("androidx.activity:activity-ktx:1.13.0")
|
||||
implementation("com.antonkarpenko:ffmpeg-kit-full:2.1.0")
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.example.temp_project
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@@ -7,6 +7,7 @@ import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
@@ -57,6 +58,8 @@ class DownloadService : Service() {
|
||||
const val EXTRA_STATUS = "status"
|
||||
const val EXTRA_REQUESTS_JSON = "requests_json"
|
||||
const val EXTRA_SETTINGS_JSON = "settings_json"
|
||||
const val EXTRA_REQUESTS_PATH = "requests_path"
|
||||
const val EXTRA_SETTINGS_PATH = "settings_path"
|
||||
private const val NATIVE_WORKER_STATE_FILE = "native_download_worker_state.json"
|
||||
private const val NATIVE_WORKER_PROGRESS_FILE = "native_download_worker_progress.json"
|
||||
private const val NATIVE_REPLAYGAIN_JOURNAL_FILE = "native_replaygain_journal.json"
|
||||
@@ -116,6 +119,19 @@ class DownloadService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
fun startNativeQueueFromFiles(context: Context, requestsPath: String, settingsPath: String = "") {
|
||||
val intent = Intent(context, DownloadService::class.java).apply {
|
||||
action = ACTION_START_NATIVE_QUEUE
|
||||
putExtra(EXTRA_REQUESTS_PATH, requestsPath)
|
||||
putExtra(EXTRA_SETTINGS_PATH, settingsPath)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun pauseNativeQueue(context: Context) {
|
||||
val intent = Intent(context, DownloadService::class.java).apply {
|
||||
action = ACTION_PAUSE_NATIVE_QUEUE
|
||||
@@ -281,8 +297,18 @@ class DownloadService : Service() {
|
||||
stopForegroundService()
|
||||
}
|
||||
ACTION_START_NATIVE_QUEUE -> {
|
||||
val requestsJson = intent.getStringExtra(EXTRA_REQUESTS_JSON) ?: "[]"
|
||||
val settingsJson = intent.getStringExtra(EXTRA_SETTINGS_JSON) ?: "{}"
|
||||
val requestsJson = readNativeQueuePayload(
|
||||
intent,
|
||||
EXTRA_REQUESTS_JSON,
|
||||
EXTRA_REQUESTS_PATH,
|
||||
"[]"
|
||||
)
|
||||
val settingsJson = readNativeQueuePayload(
|
||||
intent,
|
||||
EXTRA_SETTINGS_JSON,
|
||||
EXTRA_SETTINGS_PATH,
|
||||
"{}"
|
||||
)
|
||||
startNativeWorker(requestsJson, settingsJson)
|
||||
}
|
||||
ACTION_PAUSE_NATIVE_QUEUE -> {
|
||||
@@ -365,6 +391,36 @@ class DownloadService : Service() {
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun readNativeQueuePayload(
|
||||
intent: Intent,
|
||||
jsonExtra: String,
|
||||
pathExtra: String,
|
||||
defaultValue: String,
|
||||
): String {
|
||||
val path = intent.getStringExtra(pathExtra).orEmpty()
|
||||
if (path.isNotBlank()) {
|
||||
return try {
|
||||
val file = File(path)
|
||||
val payload = file.readText()
|
||||
if (!file.delete()) {
|
||||
android.util.Log.w(
|
||||
"DownloadService",
|
||||
"Failed to delete native worker payload file: $path"
|
||||
)
|
||||
}
|
||||
payload.ifBlank { defaultValue }
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
"DownloadService",
|
||||
"Failed to read native worker payload file: ${e.message}"
|
||||
)
|
||||
defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
return intent.getStringExtra(jsonExtra) ?: defaultValue
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
@@ -400,7 +456,15 @@ class DownloadService : Service() {
|
||||
ensureWakeLock()
|
||||
|
||||
val notification = buildNotification(0, 0)
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startNativeWorker(requestsJson: String, settingsJson: String) {
|
||||
@@ -626,7 +690,8 @@ class DownloadService : Service() {
|
||||
request.itemId,
|
||||
request.requestJson,
|
||||
request.itemJson,
|
||||
result
|
||||
result,
|
||||
settingsJson
|
||||
) {
|
||||
nativeWorkerCancelRequested ||
|
||||
nativeWorkerPaused ||
|
||||
|
||||
@@ -307,6 +307,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"
|
||||
}
|
||||
@@ -772,6 +774,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return when {
|
||||
name.endsWith(".m4a") -> ".m4a"
|
||||
name.endsWith(".mp4") -> ".mp4"
|
||||
name.endsWith(".aac") -> ".aac"
|
||||
name.endsWith(".mp3") -> ".mp3"
|
||||
name.endsWith(".opus") -> ".opus"
|
||||
name.endsWith(".flac") -> ".flac"
|
||||
@@ -783,9 +786,15 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
private fun extFromMimeType(mime: String?): String {
|
||||
return when (mime) {
|
||||
"audio/mp4" -> ".m4a"
|
||||
"audio/aac" -> ".aac"
|
||||
"audio/eac3" -> ".m4a"
|
||||
"audio/ac3" -> ".m4a"
|
||||
"audio/ac4" -> ".m4a"
|
||||
"audio/mpeg" -> ".mp3"
|
||||
"audio/ogg" -> ".opus"
|
||||
"audio/flac" -> ".flac"
|
||||
"audio/wav", "audio/x-wav", "audio/wave", "audio/vnd.wave" -> ".wav"
|
||||
"audio/aiff", "audio/x-aiff" -> ".aiff"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
@@ -1032,6 +1041,48 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a ".lrc" sidecar next to a SAF audio document. The sidecar reuses
|
||||
* the audio file's base name (e.g. "Song.flac" -> "Song.lrc") and is created
|
||||
* in the same parent directory. Used by re-enrich when the user's lyrics
|
||||
* mode requests an external/both sidecar. Best-effort: failures are logged
|
||||
* and swallowed so they never abort the metadata enrichment itself.
|
||||
*/
|
||||
private fun writeSafSidecarLrc(audioUri: Uri, lrcContent: String): Boolean {
|
||||
if (lrcContent.isBlank()) return false
|
||||
try {
|
||||
val parent = safParentDir(audioUri) ?: run {
|
||||
android.util.Log.w("SpotiFLAC", "LRC sidecar: no SAF parent dir")
|
||||
return false
|
||||
}
|
||||
val audioName = try {
|
||||
DocumentFile.fromSingleUri(this, audioUri)?.name
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return false
|
||||
val baseName = audioName.substringBeforeLast('.', audioName)
|
||||
val lrcName = "$baseName.lrc"
|
||||
|
||||
val target = createOrReuseDocumentFile(
|
||||
parent,
|
||||
"application/octet-stream",
|
||||
lrcName
|
||||
) ?: run {
|
||||
android.util.Log.w("SpotiFLAC", "LRC sidecar: failed to create $lrcName")
|
||||
return false
|
||||
}
|
||||
|
||||
contentResolver.openOutputStream(target.uri, "wt")?.use { output ->
|
||||
output.write(lrcContent.toByteArray(Charsets.UTF_8))
|
||||
} ?: return false
|
||||
android.util.Log.d("SpotiFLAC", "LRC sidecar written: $lrcName")
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "LRC sidecar write failed: ${e.message}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the audio filename referenced by a CUE sheet file.
|
||||
* Reads the FILE "name" TYPE line from the .cue text.
|
||||
@@ -1063,7 +1114,17 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
|
||||
private val cueSiblingAudioExtensions = listOf(
|
||||
".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a"
|
||||
".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(
|
||||
@@ -1135,7 +1196,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
it.currentFile = "Scanning folders..."
|
||||
}
|
||||
|
||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
||||
val supportedAudioExt = libraryScanAudioExtensions
|
||||
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
|
||||
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||
val visitedDirUris = mutableSetOf<String>()
|
||||
@@ -1435,7 +1496,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
it.currentFile = "Scanning folders..."
|
||||
}
|
||||
|
||||
val supportedAudioExt = setOf(".flac", ".m4a", ".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>>()
|
||||
@@ -2599,6 +2660,23 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"writeSafSidecarLrc" -> {
|
||||
val safUri = call.argument<String>("saf_uri") ?: ""
|
||||
val lyrics = call.argument<String>("lyrics") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val uri = Uri.parse(safUri)
|
||||
if (writeSafSidecarLrc(uri, lyrics)) {
|
||||
"""{"success":true}"""
|
||||
} else {
|
||||
"""{"success":false,"error":"Failed to write LRC sidecar"}"""
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"downloadCoverToFile" -> {
|
||||
val coverUrl = call.argument<String>("cover_url") ?: ""
|
||||
val outputPath = call.argument<String>("output_path") ?: ""
|
||||
@@ -2756,6 +2834,9 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
if (!writeUriFromPath(uri, tempPath)) {
|
||||
return@withContext """{"error":"Failed to write enriched metadata back to SAF file"}"""
|
||||
}
|
||||
if (obj.optBoolean("write_external_lrc", false)) {
|
||||
writeSafSidecarLrc(uri, obj.optString("lyrics", ""))
|
||||
}
|
||||
raw
|
||||
} catch (e: Exception) {
|
||||
try { File(tempPath).delete() } catch (_: Exception) {}
|
||||
@@ -2797,7 +2878,17 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
"startNativeDownloadWorker" -> {
|
||||
val requestsJson = call.argument<String>("requests_json") ?: "[]"
|
||||
val settingsJson = call.argument<String>("settings_json") ?: "{}"
|
||||
DownloadService.startNativeQueue(this@MainActivity, requestsJson, settingsJson)
|
||||
val requestsPath = call.argument<String>("requests_path") ?: ""
|
||||
val settingsPath = call.argument<String>("settings_path") ?: ""
|
||||
if (requestsPath.isNotBlank()) {
|
||||
DownloadService.startNativeQueueFromFiles(
|
||||
this@MainActivity,
|
||||
requestsPath,
|
||||
settingsPath
|
||||
)
|
||||
} else {
|
||||
DownloadService.startNativeQueue(this@MainActivity, requestsJson, settingsJson)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"pauseNativeDownloadWorker" -> {
|
||||
@@ -3080,6 +3171,17 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"findCollectionAcrossExtensions" -> {
|
||||
val requestJson = call.arguments as? String ?: "{}"
|
||||
val response: String = withContext(Dispatchers.IO) {
|
||||
val method = Gobackend::class.java.getMethod(
|
||||
"findCollectionAcrossExtensionsJSON",
|
||||
String::class.java
|
||||
)
|
||||
method.invoke(null, requestJson) as? String ?: "[]"
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"enrichTrackWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val trackJson = call.argument<String>("track") ?: "{}"
|
||||
@@ -3465,7 +3567,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
} catch (_: Exception) { "" }
|
||||
val cueBaseName = cueName.substringBeforeLast('.')
|
||||
if (cueBaseName.isNotBlank()) {
|
||||
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a")
|
||||
val commonExts = listOf(".flac", ".wav", ".ape", ".mp3", ".ogg", ".wv", ".m4a", ".mp4", ".aac")
|
||||
for (ext in commonExts) {
|
||||
audioDoc = try { parentDir.findFile(cueBaseName + ext) } catch (_: Exception) { null }
|
||||
if (audioDoc != null) break
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.antonkarpenko.ffmpegkit.ReturnCode
|
||||
import gobackend.Gobackend
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CancellationException
|
||||
@@ -29,7 +30,7 @@ object NativeDownloadFinalizer {
|
||||
const val NATIVE_WORKER_CONTRACT_VERSION = 1
|
||||
// Native finalizer owns background-safe history writes while Flutter may be suspended.
|
||||
// Keep this schema contract in sync with Dart HistoryDatabase before bumping either side.
|
||||
private const val HISTORY_SCHEMA_VERSION = 8
|
||||
private const val HISTORY_SCHEMA_VERSION = 9
|
||||
private val activeFFmpegSessionIds = mutableSetOf<Long>()
|
||||
private val nativeFFmpegSessionIds = mutableSetOf<Long>()
|
||||
private val activeFFmpegSessionLock = Any()
|
||||
@@ -72,6 +73,8 @@ object NativeDownloadFinalizer {
|
||||
"quality",
|
||||
"bit_depth",
|
||||
"sample_rate",
|
||||
"bitrate",
|
||||
"format",
|
||||
"genre",
|
||||
"composer",
|
||||
"label",
|
||||
@@ -95,6 +98,7 @@ object NativeDownloadFinalizer {
|
||||
".ogg",
|
||||
".wav",
|
||||
".aac",
|
||||
".mp4",
|
||||
)
|
||||
|
||||
private data class FinalizeInput(
|
||||
@@ -112,6 +116,7 @@ object NativeDownloadFinalizer {
|
||||
var bitDepth: Int?,
|
||||
var sampleRate: Int?,
|
||||
var bitrateKbps: Int? = null,
|
||||
var audioCodec: String? = null,
|
||||
var pendingExternalLrc: String? = null,
|
||||
var pendingExternalLrcFileName: String? = null,
|
||||
)
|
||||
@@ -141,6 +146,7 @@ object NativeDownloadFinalizer {
|
||||
requestJson: String,
|
||||
itemJson: String,
|
||||
result: JSONObject,
|
||||
settingsJson: String = "{}",
|
||||
shouldCancel: () -> Boolean = { false },
|
||||
): JSONObject {
|
||||
if (!result.optBoolean("success", false)) return result
|
||||
@@ -174,6 +180,9 @@ object NativeDownloadFinalizer {
|
||||
sampleRate = optPositiveInt(result, "actual_sample_rate"),
|
||||
bitrateKbps = optPositiveBitrateKbps(result, "bitrate")
|
||||
?: optPositiveBitrateKbps(result, "actual_bitrate"),
|
||||
audioCodec = normalizeAudioCodec(
|
||||
result.optString("audio_codec", "").ifBlank { result.optString("format", "") },
|
||||
),
|
||||
)
|
||||
|
||||
try {
|
||||
@@ -209,14 +218,20 @@ object NativeDownloadFinalizer {
|
||||
refreshFinalAudioQualityMetadata(context, result, state)
|
||||
}
|
||||
|
||||
val history = buildHistoryRow(effectiveInput, state)
|
||||
upsertHistory(context, history)
|
||||
val saveDownloadHistory = parseObject(settingsJson)
|
||||
.optBoolean("save_download_history", true)
|
||||
val history = if (saveDownloadHistory) {
|
||||
buildHistoryRow(effectiveInput, state).also { upsertHistory(context, it) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
result.put("file_path", state.filePath)
|
||||
if (state.fileName.isNotBlank()) result.put("file_name", state.fileName)
|
||||
if (state.quality.isNotBlank()) result.put("quality", state.quality)
|
||||
result.put("native_finalized", true)
|
||||
result.put("history_written", true)
|
||||
result.put("history_item", historyToJson(history))
|
||||
result.put("history_written", history != null)
|
||||
if (history != null) result.put("history_item", historyToJson(history))
|
||||
} catch (e: CancellationException) {
|
||||
cleanupFailedFinalizationOutput(context, result, initialPath, state.filePath)
|
||||
result.put("success", false)
|
||||
@@ -419,7 +434,13 @@ object NativeDownloadFinalizer {
|
||||
for ((candidateOutput, mapAudioOnly) in attempts) {
|
||||
try {
|
||||
val audioMap = if (mapAudioOnly) "-map 0:a " else ""
|
||||
val command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${q(candidateOutput)} -y"
|
||||
// Force the flac muxer when the target extension is
|
||||
// .flac. Without this override FFmpeg keeps the ISO-BMFF
|
||||
// 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 command = "-v error -decryption_key ${q(candidate)} -f $inputFormat -i ${q(localInput)} ${audioMap}-c copy ${muxerOverride}${q(candidateOutput)} -y"
|
||||
val result = runFFmpeg(command, shouldCancel)
|
||||
lastOutput = result.second
|
||||
if (result.first && File(candidateOutput).exists()) {
|
||||
@@ -461,13 +482,23 @@ object NativeDownloadFinalizer {
|
||||
if (!looksLikeM4a(state.filePath, state.fileName)) return
|
||||
|
||||
val tidalHighFormat = input.request.optString("tidal_high_format", "").ifBlank { "mp3_320" }
|
||||
val format = if (tidalHighFormat.startsWith("opus")) "opus" else "mp3"
|
||||
val format = when {
|
||||
tidalHighFormat.startsWith("opus") -> "opus"
|
||||
tidalHighFormat.startsWith("aac") || tidalHighFormat.startsWith("m4a") -> "aac"
|
||||
else -> "mp3"
|
||||
}
|
||||
val metadataFormat = if (format == "aac") "m4a" else format
|
||||
val displayFormat = if (format == "aac") "AAC" else format.uppercase(Locale.ROOT)
|
||||
val bitrate = if (tidalHighFormat.contains("_")) {
|
||||
"${tidalHighFormat.substringAfterLast("_")}k"
|
||||
} else {
|
||||
if (format == "opus") "128k" else "320k"
|
||||
}
|
||||
val ext = if (format == "opus") ".opus" else ".mp3"
|
||||
val ext = when (format) {
|
||||
"opus" -> ".opus"
|
||||
"aac" -> ".m4a"
|
||||
else -> ".mp3"
|
||||
}
|
||||
val localInput = materializeForFFmpeg(context, input, state)
|
||||
val deleteLocalInput = state.filePath.startsWith("content://")
|
||||
val output = buildOutputPath(localInput, ext)
|
||||
@@ -475,6 +506,8 @@ object NativeDownloadFinalizer {
|
||||
try {
|
||||
val command = if (format == "opus") {
|
||||
"-v error -hide_banner -i ${q(localInput)} -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a ${q(output)} -y"
|
||||
} else if (format == "aac") {
|
||||
"-v error -hide_banner -i ${q(localInput)} -codec:a aac -b:a $bitrate -map 0:a -f mp4 ${q(output)} -y"
|
||||
} else {
|
||||
"-v error -hide_banner -i ${q(localInput)} -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 ${q(output)} -y"
|
||||
}
|
||||
@@ -482,14 +515,14 @@ object NativeDownloadFinalizer {
|
||||
if (!result.first || !File(output).exists()) {
|
||||
throw IllegalStateException("HIGH conversion failed: ${result.second}")
|
||||
}
|
||||
embedBasicMetadata(context, output, input, format)
|
||||
embedBasicMetadata(context, output, input, metadataFormat)
|
||||
replaceStatePath(context, input, state, output, deleteOld = true)
|
||||
adoptedOutput = true
|
||||
} finally {
|
||||
if (!adoptedOutput) File(output).delete()
|
||||
if (deleteLocalInput) File(localInput).delete()
|
||||
}
|
||||
state.quality = "${format.uppercase(Locale.ROOT)} ${bitrate.removeSuffix("k")}kbps"
|
||||
state.quality = "$displayFormat ${bitrate.removeSuffix("k")}kbps"
|
||||
state.bitDepth = null
|
||||
state.sampleRate = null
|
||||
}
|
||||
@@ -501,13 +534,37 @@ object NativeDownloadFinalizer {
|
||||
shouldCancel: () -> Boolean,
|
||||
) {
|
||||
if (requestQuality(input) == "HIGH" || outputExt(input) != ".flac") return
|
||||
if (!looksLikeM4a(state.filePath, state.fileName) && !shouldForceContainerConversion(input, state)) return
|
||||
val requestedDecryptionExt = requestedDecryptionOutputExt(input)
|
||||
val forceContainerConversion = shouldForceContainerConversion(input, state)
|
||||
if (!forceContainerConversion && requestedDecryptionExt.isNotBlank() && requestedDecryptionExt != ".flac") return
|
||||
val mayNeedContainerConversion = forceContainerConversion ||
|
||||
looksLikeM4a(state.filePath, state.fileName) ||
|
||||
state.filePath.startsWith("content://")
|
||||
if (!mayNeedContainerConversion) return
|
||||
|
||||
val localInput = materializeForFFmpeg(context, input, state)
|
||||
val deleteLocalInput = state.filePath.startsWith("content://")
|
||||
val output = buildOutputPath(localInput, ".flac")
|
||||
var adoptedOutput = false
|
||||
try {
|
||||
val codec = probePrimaryAudioCodec(localInput, shouldCancel)
|
||||
val isAlreadyNativeFlac = codec == "flac" && isNativeFlacFile(localInput)
|
||||
if (!isLosslessAudioCodec(codec)) {
|
||||
Log.d(TAG, "Preserving native container; audio codec is ${codec.ifBlank { "unknown" }}")
|
||||
return
|
||||
}
|
||||
if (isAlreadyNativeFlac) {
|
||||
Log.d(TAG, "Native FLAC payload detected; publishing as FLAC and embedding metadata")
|
||||
val nativeFlacOutput = if (localInput.lowercase(Locale.ROOT).endsWith(".flac")) {
|
||||
localInput
|
||||
} else {
|
||||
File(localInput).copyTo(File(output), overwrite = true).absolutePath
|
||||
}
|
||||
embedBasicMetadata(context, nativeFlacOutput, input, "flac")
|
||||
replaceStatePath(context, input, state, nativeFlacOutput, deleteOld = true)
|
||||
adoptedOutput = true
|
||||
return
|
||||
}
|
||||
val result = runFFmpeg(
|
||||
"-v error -xerror -i ${q(localInput)} -c:a flac -compression_level 8 ${q(output)} -y",
|
||||
shouldCancel,
|
||||
@@ -633,6 +690,17 @@ object NativeDownloadFinalizer {
|
||||
|
||||
val bitDepth = optPositiveInt(metadata, "bit_depth")
|
||||
val sampleRate = optPositiveInt(metadata, "sample_rate")
|
||||
val probedCodec = normalizeAudioCodec(
|
||||
metadata.optString("audio_codec", "").ifBlank {
|
||||
metadata.optString("codec", "").ifBlank {
|
||||
metadata.optString("format", "")
|
||||
}
|
||||
}
|
||||
)
|
||||
if (probedCodec != null) {
|
||||
state.audioCodec = probedCodec
|
||||
result.put("audio_codec", probedCodec)
|
||||
}
|
||||
if (bitDepth != null) {
|
||||
state.bitDepth = bitDepth
|
||||
result.put("actual_bit_depth", bitDepth)
|
||||
@@ -643,7 +711,7 @@ object NativeDownloadFinalizer {
|
||||
}
|
||||
val bitrateKbps = optPositiveBitrateKbps(metadata, "bitrate")
|
||||
?: optPositiveBitrateKbps(metadata, "bit_rate")
|
||||
if (bitrateKbps != null) {
|
||||
if (bitrateKbps != null && isLossyAudioCodec(state.audioCodec)) {
|
||||
state.bitrateKbps = bitrateKbps
|
||||
result.put("bitrate", bitrateKbps)
|
||||
}
|
||||
@@ -654,6 +722,7 @@ object NativeDownloadFinalizer {
|
||||
bitDepth = state.bitDepth,
|
||||
sampleRate = state.sampleRate,
|
||||
bitrateKbps = state.bitrateKbps,
|
||||
audioCodec = state.audioCodec,
|
||||
storedQuality = state.quality,
|
||||
)
|
||||
if (displayQuality != null) {
|
||||
@@ -691,15 +760,19 @@ object NativeDownloadFinalizer {
|
||||
bitDepth: Int?,
|
||||
sampleRate: Int?,
|
||||
bitrateKbps: Int?,
|
||||
audioCodec: String? = null,
|
||||
storedQuality: String?,
|
||||
): String? {
|
||||
val format = audioFormatForPath(filePath, fileName)
|
||||
val format = audioFormatForCodec(audioCodec) ?: audioFormatForPath(filePath, fileName)
|
||||
if (format == "OPUS" ||
|
||||
format == "MP3" ||
|
||||
format == "AAC" ||
|
||||
format == "EAC3" ||
|
||||
format == "AC3" ||
|
||||
format == "AC4" ||
|
||||
(format == "M4A" && (bitDepth == null || bitDepth <= 0))
|
||||
) {
|
||||
return if (bitrateKbps != null && bitrateKbps > 0) {
|
||||
return if (bitrateKbps != null && bitrateKbps >= 16) {
|
||||
"$format ${bitrateKbps}kbps"
|
||||
} else {
|
||||
nonPlaceholderQuality(storedQuality) ?: format
|
||||
@@ -715,6 +788,43 @@ object NativeDownloadFinalizer {
|
||||
return nonPlaceholderQuality(storedQuality) ?: normalizeOptional(storedQuality)
|
||||
}
|
||||
|
||||
private fun audioFormatForCodec(codec: String?): String? {
|
||||
return when (normalizeAudioCodec(codec)) {
|
||||
"flac" -> "FLAC"
|
||||
"alac" -> "ALAC"
|
||||
"aac" -> "AAC"
|
||||
"eac3" -> "EAC3"
|
||||
"ac3" -> "AC3"
|
||||
"ac4" -> "AC4"
|
||||
"mp3" -> "MP3"
|
||||
"opus" -> "OPUS"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLossyAudioCodec(codec: String?): Boolean {
|
||||
return when (normalizeAudioCodec(codec)) {
|
||||
"aac", "eac3", "ac3", "ac4", "mp3", "opus", "m4a" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeAudioCodec(codec: String?): String? {
|
||||
val normalized = normalizeOptional(codec)
|
||||
?.lowercase(Locale.ROOT)
|
||||
?.replace('-', '_')
|
||||
?: return null
|
||||
return when (normalized) {
|
||||
"mp4a" -> "aac"
|
||||
"ec_3" -> "eac3"
|
||||
"ac_3" -> "ac3"
|
||||
"ac_4" -> "ac4"
|
||||
"mp4" -> "m4a"
|
||||
"ogg" -> "opus"
|
||||
else -> normalized
|
||||
}
|
||||
}
|
||||
|
||||
private fun audioFormatForPath(filePath: String, fileName: String): String? {
|
||||
for (candidate in listOf(filePath, fileName)) {
|
||||
val lower = candidate.trim().lowercase(Locale.ROOT)
|
||||
@@ -730,6 +840,11 @@ object NativeDownloadFinalizer {
|
||||
|
||||
private fun nonPlaceholderQuality(quality: String?): String? {
|
||||
val normalized = normalizeOptional(quality) ?: return null
|
||||
val bitrateMatch = Regex("\\b(\\d+)\\s*kbps\\b", RegexOption.IGNORE_CASE).find(normalized)
|
||||
if (bitrateMatch != null) {
|
||||
val bitrate = bitrateMatch.groupValues.getOrNull(1)?.toIntOrNull()
|
||||
if (bitrate != null && bitrate < 16) return null
|
||||
}
|
||||
val key = normalized.lowercase(Locale.ROOT).replace(Regex("[^a-z0-9]+"), "_").trim('_')
|
||||
val placeholders = setOf(
|
||||
"best",
|
||||
@@ -972,10 +1087,11 @@ object NativeDownloadFinalizer {
|
||||
val genre = resultString(input, "genre").ifBlank { requestString(input, "genre") }
|
||||
val label = resultString(input, "label").ifBlank { requestString(input, "label") }
|
||||
val copyright = resultString(input, "copyright").ifBlank { requestString(input, "copyright") }
|
||||
val lyrics = resolveLyricsLrc(input)
|
||||
val shouldEmbedLyrics = input.request.optBoolean("embed_lyrics", false) &&
|
||||
(input.request.optString("lyrics_mode", "embed") == "embed" ||
|
||||
input.request.optString("lyrics_mode", "embed") == "both") &&
|
||||
val lyricsMode = input.request.optString("lyrics_mode", "embed")
|
||||
val shouldResolveLyrics = input.request.optBoolean("embed_lyrics", false) &&
|
||||
(lyricsMode == "embed" || lyricsMode == "both")
|
||||
val lyrics = if (shouldResolveLyrics) resolveLyricsLrc(input) else ""
|
||||
val shouldEmbedLyrics = shouldResolveLyrics &&
|
||||
lyrics.isNotBlank() &&
|
||||
lyrics != "[instrumental:true]"
|
||||
if (format == "flac") {
|
||||
@@ -1146,7 +1262,7 @@ object NativeDownloadFinalizer {
|
||||
return when (normalizeExt(File(path).extension)) {
|
||||
".mp3" -> "mp3"
|
||||
".opus", ".ogg" -> "opus"
|
||||
".m4a", ".mp4" -> "m4a"
|
||||
".m4a", ".mp4", ".aac" -> "m4a"
|
||||
else -> "flac"
|
||||
}
|
||||
}
|
||||
@@ -1294,7 +1410,7 @@ object NativeDownloadFinalizer {
|
||||
val rawName = input.request.optString("saf_file_name", "")
|
||||
.ifBlank { state.fileName }
|
||||
.ifBlank { "${trackString(input, "artistName", input.request.optString("artist_name", "Artist"))} - ${trackString(input, "name", input.request.optString("track_name", "Track"))}" }
|
||||
val knownExts = listOf(".flac", ".m4a", ".mp4", ".mp3", ".opus", ".ogg", ".lrc")
|
||||
val knownExts = listOf(".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg", ".lrc")
|
||||
var base = rawName.trim()
|
||||
val lower = base.lowercase(Locale.ROOT)
|
||||
for (knownExt in knownExts) {
|
||||
@@ -1315,19 +1431,66 @@ object NativeDownloadFinalizer {
|
||||
private fun shouldForceContainerConversion(input: FinalizeInput, state: FinalizeState): Boolean {
|
||||
if (input.result.optBoolean("requires_container_conversion", false)) return true
|
||||
if (input.request.optBoolean("requires_container_conversion", false)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
val actualExt = normalizeExt(
|
||||
input.result.optString("actual_extension", "")
|
||||
.ifBlank { input.result.optString("output_extension", "") }
|
||||
private fun probePrimaryAudioCodec(path: String, shouldCancel: () -> Boolean = { false }): String {
|
||||
val result = runFFmpeg("-hide_banner -nostdin -i ${q(path)} -map 0:a:0 -frames:a 1 -f null -", shouldCancel)
|
||||
val output = result.second
|
||||
val match = Regex("Audio:\\s*([^,\\s]+)", RegexOption.IGNORE_CASE).find(output)
|
||||
return match?.groupValues?.getOrNull(1)
|
||||
?.trim()
|
||||
?.lowercase(Locale.ROOT)
|
||||
?.replace('-', '_')
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the file on [path] starts with the native FLAC magic
|
||||
* bytes (`fLaC`). A file may contain a FLAC audio stream yet live inside
|
||||
* an MP4/fMP4 container (e.g. some Amazon Music downloads); native FLAC
|
||||
* tag writers require the raw fLaC header, so we must detect that mismatch
|
||||
* before skipping the container conversion step.
|
||||
*/
|
||||
private fun isNativeFlacFile(path: String): Boolean {
|
||||
return try {
|
||||
RandomAccessFile(path, "r").use { raf ->
|
||||
if (raf.length() < 4L) return false
|
||||
val header = ByteArray(4)
|
||||
raf.readFully(header)
|
||||
header[0] == 0x66.toByte() && // 'f'
|
||||
header[1] == 0x4C.toByte() && // 'L'
|
||||
header[2] == 0x61.toByte() && // 'a'
|
||||
header[3] == 0x43.toByte() // 'C'
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Native FLAC magic probe failed for $path: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLosslessAudioCodec(codec: String): Boolean {
|
||||
val normalized = codec.trim().lowercase(Locale.ROOT).replace('-', '_')
|
||||
if (normalized.isBlank()) return false
|
||||
if (normalized.startsWith("pcm_")) return true
|
||||
return normalized in setOf(
|
||||
"alac",
|
||||
"flac",
|
||||
"wavpack",
|
||||
"ape",
|
||||
"tta",
|
||||
"mlp",
|
||||
"truehd",
|
||||
"shorten"
|
||||
)
|
||||
if (actualExt == ".m4a" || actualExt == ".mp4") return true
|
||||
}
|
||||
|
||||
val container = input.result.optString("actual_container", "")
|
||||
.ifBlank { input.result.optString("container", "") }
|
||||
.trim()
|
||||
.lowercase(Locale.ROOT)
|
||||
.removePrefix(".")
|
||||
return container == "m4a" || container == "mp4" || container == "mov" || container == "aac"
|
||||
private fun requestedDecryptionOutputExt(input: FinalizeInput): String {
|
||||
val descriptor = input.result.optJSONObject("decryption")
|
||||
return normalizeExt(
|
||||
descriptor?.optString("output_extension", "")
|
||||
?.ifBlank { input.result.optString("output_extension", "") }
|
||||
)
|
||||
}
|
||||
|
||||
private fun validateRequestContract(request: JSONObject) {
|
||||
@@ -1541,6 +1704,10 @@ object NativeDownloadFinalizer {
|
||||
values.put("quality", state.quality)
|
||||
state.bitDepth?.let { values.put("bit_depth", it) }
|
||||
state.sampleRate?.let { values.put("sample_rate", it) }
|
||||
state.bitrateKbps?.takeIf { it >= 16 && isLossyAudioCodec(state.audioCodec) }?.let {
|
||||
values.put("bitrate", it)
|
||||
}
|
||||
normalizeAudioCodec(state.audioCodec)?.let { values.put("format", it) }
|
||||
values.put("genre", normalizeOptional(result.optString("genre", "").ifBlank { input.request.optString("genre", "") }))
|
||||
values.put("composer", normalizeOptional(resultString(input, "composer").ifBlank { trackString(input, "composer", requestString(input, "composer")) }))
|
||||
values.put("label", normalizeOptional(result.optString("label", "").ifBlank { input.request.optString("label", "") }))
|
||||
@@ -1597,6 +1764,8 @@ object NativeDownloadFinalizer {
|
||||
quality TEXT,
|
||||
bit_depth INTEGER,
|
||||
sample_rate INTEGER,
|
||||
bitrate INTEGER,
|
||||
format TEXT,
|
||||
genre TEXT,
|
||||
composer TEXT,
|
||||
label TEXT,
|
||||
@@ -1612,6 +1781,8 @@ object NativeDownloadFinalizer {
|
||||
ensureHistoryColumn(db, "composer", "ALTER TABLE history ADD COLUMN composer TEXT")
|
||||
ensureHistoryColumn(db, "total_tracks", "ALTER TABLE history ADD COLUMN total_tracks INTEGER")
|
||||
ensureHistoryColumn(db, "total_discs", "ALTER TABLE history ADD COLUMN total_discs INTEGER")
|
||||
ensureHistoryColumn(db, "bitrate", "ALTER TABLE history ADD COLUMN bitrate INTEGER")
|
||||
ensureHistoryColumn(db, "format", "ALTER TABLE history ADD COLUMN format TEXT")
|
||||
ensureHistoryColumn(db, "spotify_id_norm", "ALTER TABLE history ADD COLUMN spotify_id_norm TEXT")
|
||||
ensureHistoryColumn(db, "isrc_norm", "ALTER TABLE history ADD COLUMN isrc_norm TEXT")
|
||||
ensureHistoryColumn(db, "match_key", "ALTER TABLE history ADD COLUMN match_key TEXT")
|
||||
@@ -1983,6 +2154,8 @@ object NativeDownloadFinalizer {
|
||||
putCamel("quality", "quality")
|
||||
putCamel("bit_depth", "bitDepth")
|
||||
putCamel("sample_rate", "sampleRate")
|
||||
putCamel("bitrate", "bitrate")
|
||||
putCamel("format", "format")
|
||||
putCamel("genre", "genre")
|
||||
putCamel("composer", "composer")
|
||||
putCamel("label", "label")
|
||||
@@ -2014,11 +2187,12 @@ object NativeDownloadFinalizer {
|
||||
|
||||
private fun optPositiveBitrateKbps(obj: JSONObject, key: String): Int? {
|
||||
val value = optPositiveInt(obj, key) ?: return null
|
||||
return if (value >= 10000) {
|
||||
val kbps = if (value >= 10000) {
|
||||
Math.round(value / 1000.0).toInt()
|
||||
} else {
|
||||
value
|
||||
}
|
||||
return if (kbps >= 16) kbps else null
|
||||
}
|
||||
|
||||
private fun positiveOrNull(primary: Int, fallback: Int): Int? {
|
||||
|
||||
@@ -15,6 +15,7 @@ import java.util.Locale
|
||||
object SafDownloadHandler {
|
||||
private val safDirLock = Any()
|
||||
private const val MAX_SAF_DISPLAY_NAME_UTF8_BYTES = 180
|
||||
private const val STAGED_SAF_MIME_TYPE = "application/octet-stream"
|
||||
|
||||
fun handle(context: Context, requestJson: String, downloader: (String) -> String): String {
|
||||
val req = JSONObject(requestJson)
|
||||
@@ -31,15 +32,15 @@ object SafDownloadHandler {
|
||||
val fileName = buildSafFileName(req, outputExt)
|
||||
val deferSafPublish = req.optBoolean("defer_saf_publish", false)
|
||||
val useStagedOutput = req.optBoolean("stage_saf_output", false) && !deferSafPublish
|
||||
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName, outputExt) else fileName
|
||||
val staleStagedFileName = buildStagedSafFileName(fileName, outputExt)
|
||||
val stagedFileName = if (useStagedOutput) buildStagedSafFileName(fileName) else fileName
|
||||
val stagedMimeType = if (useStagedOutput) STAGED_SAF_MIME_TYPE else mimeType
|
||||
|
||||
val existingDir = findDocumentDir(context, treeUri, relativeDir)
|
||||
if (existingDir != null) {
|
||||
val existing = existingDir.findFile(fileName)
|
||||
if (existing != null && existing.isFile && existing.length() > 0) {
|
||||
if (useStagedOutput || deferSafPublish) {
|
||||
existingDir.findFile(staleStagedFileName)?.delete()
|
||||
deleteStaleStagedFiles(existingDir, fileName, outputExt)
|
||||
}
|
||||
val obj = JSONObject()
|
||||
obj.put("success", true)
|
||||
@@ -55,7 +56,7 @@ object SafDownloadHandler {
|
||||
?: return errorJson("Failed to access SAF directory")
|
||||
|
||||
if (deferSafPublish) {
|
||||
targetDir.findFile(staleStagedFileName)?.delete()
|
||||
deleteStaleStagedFiles(targetDir, fileName, outputExt)
|
||||
val workingExt = outputExt.ifBlank { ".tmp" }
|
||||
val workingFile = File.createTempFile("native_saf_work_", workingExt, context.cacheDir)
|
||||
Log.i("SpotiFLAC", "SAF deferred native output: target=$fileName working=${workingFile.name}")
|
||||
@@ -89,7 +90,7 @@ object SafDownloadHandler {
|
||||
}
|
||||
}
|
||||
|
||||
var document = createOrReuseDocumentFile(targetDir, mimeType, stagedFileName)
|
||||
var document = createOrReuseDocumentFile(targetDir, stagedMimeType, stagedFileName)
|
||||
?: return errorJson("Failed to create SAF file")
|
||||
|
||||
val pfd = context.contentResolver.openFileDescriptor(document.uri, "rw")
|
||||
@@ -121,14 +122,14 @@ object SafDownloadHandler {
|
||||
if (actualExt.isNotBlank() && actualExt != outputExt) {
|
||||
val actualFileName = buildSafFileName(req, actualExt)
|
||||
val actualStagedFileName = if (useStagedOutput) {
|
||||
buildStagedSafFileName(actualFileName, actualExt)
|
||||
buildStagedSafFileName(actualFileName)
|
||||
} else {
|
||||
actualFileName
|
||||
}
|
||||
val actualMimeType = mimeTypeForExt(actualExt)
|
||||
val replacement = createOrReuseDocumentFile(
|
||||
targetDir,
|
||||
actualMimeType,
|
||||
if (useStagedOutput) STAGED_SAF_MIME_TYPE else actualMimeType,
|
||||
actualStagedFileName
|
||||
) ?: throw IllegalStateException(
|
||||
"failed to create SAF output with actual extension"
|
||||
@@ -212,8 +213,9 @@ object SafDownloadHandler {
|
||||
val targetDir = ensureDocumentDir(context, treeUri, relativeDir) ?: return null
|
||||
val finalName = sanitizeFilename(fileName)
|
||||
val ext = normalizeExt(finalName.substringAfterLast('.', ""))
|
||||
val stagedName = buildStagedSafFileName(finalName, ext)
|
||||
val document = createOrReuseDocumentFile(targetDir, mimeType, stagedName)
|
||||
val stagedName = buildStagedSafFileName(finalName)
|
||||
deleteStaleStagedFiles(targetDir, finalName, ext)
|
||||
val document = createOrReuseDocumentFile(targetDir, STAGED_SAF_MIME_TYPE, stagedName)
|
||||
?: return null
|
||||
stagedDocument = document
|
||||
val outputStream = context.contentResolver.openOutputStream(document.uri, "wt")
|
||||
@@ -288,13 +290,17 @@ object SafDownloadHandler {
|
||||
return safeName + normalizedExt
|
||||
}
|
||||
|
||||
private fun buildStagedSafFileName(fileName: String, outputExt: String): String {
|
||||
private fun buildStagedSafFileName(fileName: String): String {
|
||||
val safeName = sanitizeFilename(fileName)
|
||||
return "$safeName.partial"
|
||||
}
|
||||
|
||||
private fun buildLegacyStagedSafFileName(fileName: String, outputExt: String): String {
|
||||
val safeName = sanitizeFilename(fileName)
|
||||
val ext = normalizeExt(outputExt)
|
||||
if (ext.isNotBlank() && safeName.lowercase(Locale.ROOT).endsWith(ext)) {
|
||||
return safeName.dropLast(ext.length).trimEnd('.', ' ') + ".partial$ext"
|
||||
}
|
||||
|
||||
val dot = safeName.lastIndexOf('.')
|
||||
if (dot > 0 && dot < safeName.lastIndex) {
|
||||
return safeName.substring(0, dot).trimEnd('.', ' ') +
|
||||
@@ -304,6 +310,19 @@ object SafDownloadHandler {
|
||||
return "$safeName.partial"
|
||||
}
|
||||
|
||||
private fun deleteStaleStagedFiles(parent: DocumentFile, fileName: String, outputExt: String) {
|
||||
val stagedNames = linkedSetOf(
|
||||
buildStagedSafFileName(fileName),
|
||||
buildLegacyStagedSafFileName(fileName, outputExt)
|
||||
)
|
||||
for (stagedName in stagedNames) {
|
||||
try {
|
||||
parent.findFile(stagedName)?.delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sanitizeFilename(name: String): String {
|
||||
var sanitized = name
|
||||
.replace("/", " ")
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -19,7 +19,7 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.13.2" apply false
|
||||
id("com.android.application") version "9.2.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.21" apply false
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"name": "SpotiFLAC Mobile",
|
||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||
"developerName": "zarzet",
|
||||
"version": "4.3.1",
|
||||
"versionDate": "2026-04-14",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.3.1/SpotiFLAC-v4.3.1-ios-unsigned.ipa",
|
||||
"version": "4.5.6",
|
||||
"versionDate": "2026-06-01",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v4.5.6/SpotiFLAC-v4.5.6-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": 34773644
|
||||
"size": 34059797
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 539 KiB After Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 811 KiB After Width: | Height: | Size: 811 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -308,16 +308,20 @@ func TestM4AMetadataAtomHelpers(t *testing.T) {
|
||||
t.Fatalf("ReplayGain fields = %#v", fields)
|
||||
}
|
||||
|
||||
qualityPath := filepath.Join(dir, "quality.m4a")
|
||||
qualityPath := filepath.Join(dir, "quality-alac.m4a")
|
||||
mvhd := make([]byte, 20)
|
||||
binary.BigEndian.PutUint32(mvhd[12:16], 1000)
|
||||
binary.BigEndian.PutUint32(mvhd[16:20], 180000)
|
||||
sampleEntry := make([]byte, 32)
|
||||
copy(sampleEntry[0:4], "mp4a")
|
||||
copy(sampleEntry[0:4], "alac")
|
||||
binary.BigEndian.PutUint16(sampleEntry[22:24], 24)
|
||||
sampleEntry[28] = 0xAC
|
||||
sampleEntry[29] = 0x44
|
||||
qualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), sampleEntry...))...)
|
||||
alacConfig := make([]byte, 24)
|
||||
alacConfig[5] = 24
|
||||
binary.BigEndian.PutUint32(alacConfig[20:24], 44100)
|
||||
alacEntryPayload := append(append([]byte{}, sampleEntry[4:]...), buildM4AAtom("alac", alacConfig)...)
|
||||
qualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), buildM4AAtom("alac", alacEntryPayload)...))...)
|
||||
if err := os.WriteFile(qualityPath, qualityFile, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -327,6 +331,37 @@ func TestM4AMetadataAtomHelpers(t *testing.T) {
|
||||
if quality, err := GetAudioQuality(qualityPath); err != nil || quality.SampleRate != 44100 {
|
||||
t.Fatalf("GetAudioQuality M4A = %#v/%v", quality, err)
|
||||
}
|
||||
aacQualityPath := filepath.Join(dir, "quality-aac.m4a")
|
||||
copy(sampleEntry[0:4], "mp4a")
|
||||
aacQualityFile := append(buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")), buildM4AAtom("moov", append(buildM4AAtom("mvhd", mvhd), sampleEntry...))...)
|
||||
if err := os.WriteFile(aacQualityPath, aacQualityFile, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quality, err := GetM4AQuality(aacQualityPath); err != nil || quality.BitDepth != 0 || quality.SampleRate != 44100 || quality.Duration != 180 {
|
||||
t.Fatalf("GetM4AQuality AAC = %#v/%v", quality, err)
|
||||
}
|
||||
eac3QualityPath := filepath.Join(dir, "quality-eac3.m4a")
|
||||
zeroMvhd := make([]byte, 20)
|
||||
eac3SampleEntry := make([]byte, 32)
|
||||
copy(eac3SampleEntry[0:4], "ec-3")
|
||||
eac3SampleEntry[28] = 0xBB
|
||||
eac3SampleEntry[29] = 0x80
|
||||
mdhd := make([]byte, 20)
|
||||
binary.BigEndian.PutUint32(mdhd[12:16], 48000)
|
||||
binary.BigEndian.PutUint32(mdhd[16:20], 48000*123)
|
||||
eac3QualityFile := append(
|
||||
buildM4AAtom("ftyp", []byte("M4A \x00\x00\x00\x00")),
|
||||
buildM4AAtom("moov", append(
|
||||
append(buildM4AAtom("mvhd", zeroMvhd), buildM4AAtom("trak", buildM4AAtom("mdia", buildM4AAtom("mdhd", mdhd)))...),
|
||||
eac3SampleEntry...,
|
||||
))...,
|
||||
)
|
||||
if err := os.WriteFile(eac3QualityPath, eac3QualityFile, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if quality, err := GetM4AQuality(eac3QualityPath); err != nil || quality.Codec != "eac3" || quality.Duration != 123 {
|
||||
t.Fatalf("GetM4AQuality EAC3 mdhd fallback = %#v/%v", quality, err)
|
||||
}
|
||||
if _, _, ok := parseALACSpecificConfig(make([]byte, 4)); ok {
|
||||
t.Fatal("short ALAC config should not parse")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type CrossExtensionShareResult struct {
|
||||
ExtensionID string `json:"extension_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Found bool `json:"found"`
|
||||
URL string `json:"url,omitempty"`
|
||||
ItemName string `json:"item_name,omitempty"`
|
||||
ItemArtists string `json:"item_artists,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
var crossExtensionShareResultCache = struct {
|
||||
sync.RWMutex
|
||||
entries map[string]string
|
||||
order []string
|
||||
}{
|
||||
entries: make(map[string]string),
|
||||
}
|
||||
|
||||
const crossExtensionShareResultCacheLimit = 128
|
||||
|
||||
func FindCollectionAcrossExtensionsJSON(requestJSON string) (string, error) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Artists string `json:"artists"`
|
||||
Type string `json:"type"`
|
||||
SourceExtensionID string `json:"source_extension_id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.Artists = strings.TrimSpace(req.Artists)
|
||||
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
|
||||
req.SourceExtensionID = strings.TrimSpace(req.SourceExtensionID)
|
||||
if req.Name == "" {
|
||||
return "[]", nil
|
||||
}
|
||||
if req.Type == "" {
|
||||
req.Type = "album"
|
||||
}
|
||||
|
||||
providers := getExtensionManager().GetMetadataProviders()
|
||||
work := make([]*extensionProviderWrapper, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
if provider == nil || provider.extension == nil {
|
||||
continue
|
||||
}
|
||||
if provider.extension.ID == req.SourceExtensionID {
|
||||
continue
|
||||
}
|
||||
work = append(work, provider)
|
||||
}
|
||||
cacheKey := crossExtensionShareCacheKey(req.Name, req.Artists, req.Type, req.SourceExtensionID, work)
|
||||
if cached := getCrossExtensionShareCache(cacheKey); cached != "" {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
query := req.Name
|
||||
if req.Artists != "" {
|
||||
query += " " + req.Artists
|
||||
}
|
||||
|
||||
results := make([]CrossExtensionShareResult, len(work))
|
||||
var wg sync.WaitGroup
|
||||
for i, provider := range work {
|
||||
wg.Add(1)
|
||||
go func(index int, p *extensionProviderWrapper) {
|
||||
defer wg.Done()
|
||||
results[index] = findCollectionForExtension(
|
||||
p,
|
||||
req.Type,
|
||||
req.Name,
|
||||
req.Artists,
|
||||
query,
|
||||
)
|
||||
}(i, provider)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
data, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
return "[]", err
|
||||
}
|
||||
response := string(data)
|
||||
if crossExtensionShareResultsCacheable(results) {
|
||||
setCrossExtensionShareCache(cacheKey, response)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func crossExtensionShareCacheKey(name string, artists string, itemType string, sourceExtensionID string, providers []*extensionProviderWrapper) string {
|
||||
providerKeys := make([]string, 0, len(providers))
|
||||
for _, provider := range providers {
|
||||
if provider == nil || provider.extension == nil {
|
||||
continue
|
||||
}
|
||||
ext := provider.extension
|
||||
displayName := ""
|
||||
if ext.Manifest != nil {
|
||||
displayName = ext.Manifest.DisplayName
|
||||
}
|
||||
providerKeys = append(providerKeys, strings.Join([]string{
|
||||
strings.TrimSpace(ext.ID),
|
||||
strings.TrimSpace(displayName),
|
||||
strings.TrimSpace(ext.SourceDir),
|
||||
}, "\x1f"))
|
||||
}
|
||||
sort.Strings(providerKeys)
|
||||
|
||||
return strings.Join([]string{
|
||||
normalizeLooseTitle(itemType),
|
||||
normalizeLooseTitle(name),
|
||||
normalizeLooseArtistName(artists),
|
||||
strings.TrimSpace(sourceExtensionID),
|
||||
strings.Join(providerKeys, "\x1e"),
|
||||
}, "\x1d")
|
||||
}
|
||||
|
||||
func getCrossExtensionShareCache(key string) string {
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
crossExtensionShareResultCache.RLock()
|
||||
defer crossExtensionShareResultCache.RUnlock()
|
||||
return crossExtensionShareResultCache.entries[key]
|
||||
}
|
||||
|
||||
func setCrossExtensionShareCache(key string, value string) {
|
||||
if key == "" || value == "" {
|
||||
return
|
||||
}
|
||||
crossExtensionShareResultCache.Lock()
|
||||
defer crossExtensionShareResultCache.Unlock()
|
||||
|
||||
if _, exists := crossExtensionShareResultCache.entries[key]; !exists {
|
||||
crossExtensionShareResultCache.order = append(crossExtensionShareResultCache.order, key)
|
||||
}
|
||||
crossExtensionShareResultCache.entries[key] = value
|
||||
|
||||
for len(crossExtensionShareResultCache.order) > crossExtensionShareResultCacheLimit {
|
||||
oldest := crossExtensionShareResultCache.order[0]
|
||||
crossExtensionShareResultCache.order = crossExtensionShareResultCache.order[1:]
|
||||
delete(crossExtensionShareResultCache.entries, oldest)
|
||||
}
|
||||
}
|
||||
|
||||
func crossExtensionShareResultsCacheable(results []CrossExtensionShareResult) bool {
|
||||
for _, result := range results {
|
||||
if result.Found {
|
||||
continue
|
||||
}
|
||||
errText := strings.ToLower(strings.TrimSpace(result.Error))
|
||||
if errText == "" ||
|
||||
errText == "no results" ||
|
||||
errText == "unsupported collection type" ||
|
||||
strings.HasSuffix(errText, " not found") ||
|
||||
strings.Contains(errText, "found without shareable link") {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func findCollectionForExtension(
|
||||
provider *extensionProviderWrapper,
|
||||
itemType string,
|
||||
name string,
|
||||
artists string,
|
||||
query string,
|
||||
) CrossExtensionShareResult {
|
||||
result := CrossExtensionShareResult{
|
||||
ExtensionID: provider.extension.ID,
|
||||
}
|
||||
if provider.extension.Manifest != nil {
|
||||
result.DisplayName = provider.extension.Manifest.DisplayName
|
||||
}
|
||||
if result.DisplayName == "" {
|
||||
result.DisplayName = provider.extension.ID
|
||||
}
|
||||
|
||||
searchResult, err := searchCollectionCandidates(provider, itemType, query)
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
return result
|
||||
}
|
||||
if searchResult == nil || len(searchResult.Tracks) == 0 {
|
||||
result.Error = "no results"
|
||||
return result
|
||||
}
|
||||
|
||||
var best *ExtTrackMetadata
|
||||
switch itemType {
|
||||
case "artist":
|
||||
best = bestArtistTrack(searchResult.Tracks, name)
|
||||
case "album":
|
||||
best = bestAlbumTrack(searchResult.Tracks, name, artists)
|
||||
default:
|
||||
result.Error = "unsupported collection type"
|
||||
return result
|
||||
}
|
||||
if best == nil {
|
||||
result.Error = itemType + " not found"
|
||||
return result
|
||||
}
|
||||
|
||||
url := resolveCollectionShareURL(provider.extension, itemType, best)
|
||||
if url == "" {
|
||||
result.Error = itemType + " found without shareable link"
|
||||
return result
|
||||
}
|
||||
|
||||
result.Found = true
|
||||
result.URL = url
|
||||
if itemType == "artist" {
|
||||
result.ItemName = collectionArtistName(*best)
|
||||
} else {
|
||||
result.ItemName = collectionAlbumName(*best)
|
||||
result.ItemArtists = best.Artists
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func searchCollectionCandidates(provider *extensionProviderWrapper, itemType string, query string) (*ExtSearchResult, error) {
|
||||
filter := ""
|
||||
switch itemType {
|
||||
case "album":
|
||||
filter = "albums"
|
||||
case "artist":
|
||||
filter = "artists"
|
||||
}
|
||||
|
||||
if filter != "" {
|
||||
tracks, err := provider.CustomSearch(query, map[string]interface{}{
|
||||
"filter": filter,
|
||||
"limit": 10,
|
||||
})
|
||||
if err == nil && len(tracks) > 0 {
|
||||
return &ExtSearchResult{Tracks: tracks, Total: len(tracks)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return provider.SearchTracks(query, 10)
|
||||
}
|
||||
|
||||
func bestAlbumTrack(tracks []ExtTrackMetadata, albumName string, artists string) *ExtTrackMetadata {
|
||||
targetAlbum := normalizeLooseTitle(albumName)
|
||||
targetArtists := normalizeLooseArtistName(artists)
|
||||
bestScore := 0
|
||||
bestIndex := -1
|
||||
|
||||
for i := range tracks {
|
||||
track := tracks[i]
|
||||
album := normalizeLooseTitle(collectionAlbumName(track))
|
||||
trackArtists := normalizeLooseArtistName(track.Artists + " " + track.AlbumArtist)
|
||||
|
||||
score := 0
|
||||
if isCollectionItemType(track, "album") {
|
||||
score += 25
|
||||
}
|
||||
if album == targetAlbum {
|
||||
score += 100
|
||||
} else if album != "" && targetAlbum != "" && (strings.Contains(album, targetAlbum) || strings.Contains(targetAlbum, album)) {
|
||||
score += 50
|
||||
}
|
||||
if targetArtists != "" && (strings.Contains(trackArtists, targetArtists) || strings.Contains(targetArtists, trackArtists)) {
|
||||
score += 30
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
if bestIndex < 0 || bestScore < 50 {
|
||||
return nil
|
||||
}
|
||||
return &tracks[bestIndex]
|
||||
}
|
||||
|
||||
func bestArtistTrack(tracks []ExtTrackMetadata, artistName string) *ExtTrackMetadata {
|
||||
targetArtist := normalizeLooseArtistName(artistName)
|
||||
bestScore := 0
|
||||
bestIndex := -1
|
||||
|
||||
for i := range tracks {
|
||||
artist := normalizeLooseArtistName(collectionArtistName(tracks[i]))
|
||||
score := 0
|
||||
if isCollectionItemType(tracks[i], "artist") {
|
||||
score += 25
|
||||
}
|
||||
if artist == targetArtist {
|
||||
score += 100
|
||||
} else if artist != "" && targetArtist != "" && (strings.Contains(artist, targetArtist) || strings.Contains(targetArtist, artist)) {
|
||||
score += 60
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
if bestIndex < 0 || bestScore < 60 {
|
||||
return nil
|
||||
}
|
||||
return &tracks[bestIndex]
|
||||
}
|
||||
|
||||
func resolveCollectionShareURL(ext *loadedExtension, itemType string, track *ExtTrackMetadata) string {
|
||||
if track == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if itemType == "album" {
|
||||
if isCollectionItemType(*track, "album") {
|
||||
if url := normalizeShareURL(track.ExternalURL); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
if url := normalizeShareURL(track.AlbumURL); url != "" {
|
||||
return url
|
||||
}
|
||||
if url := urlFromExternalLinks(track.ExternalLinks, "album"); url != "" {
|
||||
return url
|
||||
}
|
||||
if url := templateShareURL(ext, "album", firstNonEmptyString(track.AlbumID, collectionID(*track, "album"), track.AlbumURL)); url != "" {
|
||||
return url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if isCollectionItemType(*track, "artist") {
|
||||
if url := normalizeShareURL(track.ExternalURL); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
if url := normalizeShareURL(track.ArtistURL); url != "" {
|
||||
return url
|
||||
}
|
||||
if url := urlFromExternalLinks(track.ExternalLinks, "artist"); url != "" {
|
||||
return url
|
||||
}
|
||||
if url := templateShareURL(ext, "artist", firstNonEmptyString(track.ArtistID, collectionID(*track, "artist"))); url != "" {
|
||||
return url
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func collectionAlbumName(track ExtTrackMetadata) string {
|
||||
if isCollectionItemType(track, "album") {
|
||||
return track.Name
|
||||
}
|
||||
return track.AlbumName
|
||||
}
|
||||
|
||||
func collectionArtistName(track ExtTrackMetadata) string {
|
||||
if isCollectionItemType(track, "artist") {
|
||||
return track.Name
|
||||
}
|
||||
return track.Artists
|
||||
}
|
||||
|
||||
func collectionID(track ExtTrackMetadata, itemType string) string {
|
||||
if isCollectionItemType(track, itemType) {
|
||||
return track.ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isCollectionItemType(track ExtTrackMetadata, itemType string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(track.ItemType), itemType)
|
||||
}
|
||||
|
||||
func normalizeShareURL(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if strings.HasPrefix(trimmed, "http://") || strings.HasPrefix(trimmed, "https://") {
|
||||
return trimmed
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func urlFromExternalLinks(links map[string]string, preferredKey string) string {
|
||||
for key, value := range links {
|
||||
if strings.Contains(strings.ToLower(key), preferredKey) {
|
||||
if url := normalizeShareURL(value); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func templateShareURL(ext *loadedExtension, itemType string, id string) string {
|
||||
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
|
||||
return ""
|
||||
}
|
||||
id = stripProviderPrefix(strings.TrimSpace(id))
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
templates, ok := ext.Manifest.Capabilities["shareUrlTemplates"].(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
rawTemplate, ok := templates[itemType].(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
rawTemplate = strings.TrimSpace(rawTemplate)
|
||||
if rawTemplate == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ReplaceAll(rawTemplate, "{id}", id)
|
||||
}
|
||||
|
||||
func stripProviderPrefix(id string) string {
|
||||
if index := strings.Index(id, ":"); index > 0 && index < len(id)-1 {
|
||||
return id[index+1:]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossExtensionShareUsesAlbumCollectionItems(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
Manifest: &ExtensionManifest{
|
||||
Capabilities: map[string]interface{}{
|
||||
"shareUrlTemplates": map[string]interface{}{
|
||||
"album": "https://music.apple.com/us/album/{id}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "1440783617",
|
||||
Name: "Nevermind",
|
||||
Artists: "Nirvana",
|
||||
ItemType: "album",
|
||||
},
|
||||
}
|
||||
|
||||
best := bestAlbumTrack(tracks, "Nevermind", "Nirvana")
|
||||
if best == nil {
|
||||
t.Fatal("expected album collection item to match")
|
||||
}
|
||||
if url := resolveCollectionShareURL(ext, "album", best); url != "https://music.apple.com/us/album/1440783617" {
|
||||
t.Fatalf("album share URL = %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossExtensionShareUsesArtistCollectionItems(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
Manifest: &ExtensionManifest{
|
||||
Capabilities: map[string]interface{}{
|
||||
"shareUrlTemplates": map[string]interface{}{
|
||||
"artist": "https://music.youtube.com/browse/{id}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "UCrPe3hLA51968GwxHSZ1llw",
|
||||
Name: "Nirvana",
|
||||
ItemType: "artist",
|
||||
},
|
||||
}
|
||||
|
||||
best := bestArtistTrack(tracks, "Nirvana")
|
||||
if best == nil {
|
||||
t.Fatal("expected artist collection item to match")
|
||||
}
|
||||
if url := resolveCollectionShareURL(ext, "artist", best); url != "https://music.youtube.com/browse/UCrPe3hLA51968GwxHSZ1llw" {
|
||||
t.Fatalf("artist share URL = %q", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossExtensionShareCacheKeyIsProviderOrderStable(t *testing.T) {
|
||||
apple := &extensionProviderWrapper{
|
||||
extension: &loadedExtension{
|
||||
ID: "apple",
|
||||
SourceDir: "/extensions/apple",
|
||||
Manifest: &ExtensionManifest{DisplayName: "Apple Music"},
|
||||
},
|
||||
}
|
||||
qobuz := &extensionProviderWrapper{
|
||||
extension: &loadedExtension{
|
||||
ID: "qobuz",
|
||||
SourceDir: "/extensions/qobuz",
|
||||
Manifest: &ExtensionManifest{DisplayName: "Qobuz"},
|
||||
},
|
||||
}
|
||||
|
||||
first := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{apple, qobuz})
|
||||
second := crossExtensionShareCacheKey("Nevermind", "Nirvana", "album", "spotify", []*extensionProviderWrapper{qobuz, apple})
|
||||
if first != second {
|
||||
t.Fatalf("cache key should not depend on provider order:\n%s\n%s", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossExtensionShareCacheableSkipsTransientErrors(t *testing.T) {
|
||||
cacheable := []CrossExtensionShareResult{
|
||||
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
|
||||
{ExtensionID: "qobuz", Error: "album not found"},
|
||||
{ExtensionID: "tidal", Error: "no results"},
|
||||
}
|
||||
if !crossExtensionShareResultsCacheable(cacheable) {
|
||||
t.Fatal("expected found and deterministic not-found results to be cacheable")
|
||||
}
|
||||
|
||||
transient := []CrossExtensionShareResult{
|
||||
{ExtensionID: "apple", Found: true, URL: "https://music.apple.com/us/album/1"},
|
||||
{ExtensionID: "qobuz", Error: "request failed: timeout"},
|
||||
}
|
||||
if crossExtensionShareResultsCacheable(transient) {
|
||||
t.Fatal("expected transient extension errors to skip cache")
|
||||
}
|
||||
}
|
||||
@@ -264,7 +264,7 @@ func ResolveCueAudioPath(cuePath string, cueFileName string) string {
|
||||
}
|
||||
|
||||
baseName := strings.TrimSuffix(cueFileName, filepath.Ext(cueFileName))
|
||||
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 {
|
||||
|
||||
@@ -313,6 +313,7 @@ type DownloadResponse struct {
|
||||
AlreadyExists bool `json:"already_exists,omitempty"`
|
||||
ActualBitDepth int `json:"actual_bit_depth,omitempty"`
|
||||
ActualSampleRate int `json:"actual_sample_rate,omitempty"`
|
||||
AudioCodec string `json:"audio_codec,omitempty"`
|
||||
ActualExtension string `json:"actual_extension,omitempty"`
|
||||
ActualContainer string `json:"actual_container,omitempty"`
|
||||
RequiresContainerConversion bool `json:"requires_container_conversion,omitempty"`
|
||||
@@ -342,6 +343,7 @@ type DownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
AudioCodec string
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
@@ -377,6 +379,7 @@ type reEnrichRequest struct {
|
||||
CoverURL string `json:"cover_url"`
|
||||
MaxQuality bool `json:"max_quality"`
|
||||
EmbedLyrics bool `json:"embed_lyrics"`
|
||||
LyricsMode string `json:"lyrics_mode,omitempty"`
|
||||
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
|
||||
SpotifyID string `json:"spotify_id"`
|
||||
TrackName string `json:"track_name"`
|
||||
@@ -412,6 +415,21 @@ func (r *reEnrichRequest) shouldUpdateField(field string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// lyricsEmbedEnabled reports whether lyrics should be written into the audio
|
||||
// file's tags. It mirrors the download path semantics: 'embed' and 'both' embed,
|
||||
// 'external' does not. An empty mode keeps the legacy behavior (embed) so older
|
||||
// callers that do not send lyrics_mode are unaffected.
|
||||
func (r *reEnrichRequest) lyricsEmbedEnabled() bool {
|
||||
return strings.ToLower(strings.TrimSpace(r.LyricsMode)) != "external"
|
||||
}
|
||||
|
||||
// lyricsSidecarEnabled reports whether a .lrc sidecar file should be written
|
||||
// next to the audio file. Only 'external' and 'both' request a sidecar.
|
||||
func (r *reEnrichRequest) lyricsSidecarEnabled() bool {
|
||||
mode := strings.ToLower(strings.TrimSpace(r.LyricsMode))
|
||||
return mode == "external" || mode == "both"
|
||||
}
|
||||
|
||||
func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
|
||||
if req == nil {
|
||||
return
|
||||
@@ -576,7 +594,7 @@ func buildReEnrichFFmpegMetadata(req *reEnrichRequest, lyricsLRC string) map[str
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("lyrics") {
|
||||
if lyricsLRC != "" {
|
||||
if lyricsLRC != "" && req.lyricsEmbedEnabled() {
|
||||
metadata["LYRICS"] = lyricsLRC
|
||||
metadata["UNSYNCEDLYRICS"] = lyricsLRC
|
||||
}
|
||||
@@ -592,12 +610,24 @@ func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *Ex
|
||||
downloadReq := reEnrichDownloadRequest(req)
|
||||
currentISRC := strings.TrimSpace(req.ISRC)
|
||||
currentAlbum := strings.TrimSpace(req.AlbumName)
|
||||
effectiveTrackName := req.TrackName
|
||||
if isPlaceholderReEnrichValue(effectiveTrackName) {
|
||||
effectiveTrackName = ""
|
||||
}
|
||||
effectiveArtistName := req.ArtistName
|
||||
if isPlaceholderReEnrichValue(effectiveArtistName) {
|
||||
effectiveArtistName = ""
|
||||
}
|
||||
var best *ExtTrackMetadata
|
||||
bestScore := -1 << 30
|
||||
|
||||
for i := range tracks {
|
||||
track := &tracks[i]
|
||||
score := 0
|
||||
exactISRCMatch := currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC))
|
||||
titleMatches := effectiveTrackName != "" && track.Name != "" && titlesMatch(effectiveTrackName, track.Name)
|
||||
artistMatches := effectiveArtistName != "" && track.Artists != "" && artistsMatch(effectiveArtistName, track.Artists)
|
||||
albumMatches := currentAlbum != "" && track.AlbumName != "" && titlesMatch(currentAlbum, track.AlbumName)
|
||||
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: track.Name,
|
||||
@@ -605,22 +635,39 @@ func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *Ex
|
||||
ISRC: track.ISRC,
|
||||
Duration: track.DurationMS / 1000,
|
||||
}
|
||||
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
|
||||
verified := trackMatchesRequest(downloadReq, resolved, "ReEnrich")
|
||||
|
||||
if !exactISRCMatch {
|
||||
if effectiveTrackName != "" && !titleMatches {
|
||||
continue
|
||||
}
|
||||
if effectiveArtistName != "" && !artistMatches {
|
||||
continue
|
||||
}
|
||||
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum != "" && !albumMatches {
|
||||
continue
|
||||
}
|
||||
if effectiveTrackName == "" && effectiveArtistName == "" && currentAlbum == "" && !verified {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if verified {
|
||||
score += 2000
|
||||
}
|
||||
|
||||
if currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC)) {
|
||||
if exactISRCMatch {
|
||||
score += 10000
|
||||
}
|
||||
if req.TrackName != "" && track.Name != "" && titlesMatch(req.TrackName, track.Name) {
|
||||
if titleMatches {
|
||||
score += 400
|
||||
}
|
||||
if req.ArtistName != "" && track.Artists != "" && artistsMatch(req.ArtistName, track.Artists) {
|
||||
if artistMatches {
|
||||
score += 320
|
||||
}
|
||||
if currentAlbum != "" && track.AlbumName != "" {
|
||||
switch {
|
||||
case titlesMatch(currentAlbum, track.AlbumName):
|
||||
case albumMatches:
|
||||
score += 120
|
||||
case strings.Contains(strings.ToLower(track.AlbumName), strings.ToLower(currentAlbum)),
|
||||
strings.Contains(strings.ToLower(currentAlbum), strings.ToLower(track.AlbumName)):
|
||||
@@ -863,6 +910,7 @@ func buildDownloadSuccessResponse(
|
||||
AlreadyExists: alreadyExists,
|
||||
ActualBitDepth: result.BitDepth,
|
||||
ActualSampleRate: result.SampleRate,
|
||||
AudioCodec: result.AudioCodec,
|
||||
ActualExtension: result.ActualExtension,
|
||||
ActualContainer: result.ActualContainer,
|
||||
RequiresContainerConversion: result.RequiresContainerConversion,
|
||||
@@ -920,7 +968,12 @@ func enrichResultQualityFromFile(result *DownloadResult) {
|
||||
if qErr == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
result.AudioCodec = quality.Codec
|
||||
if quality.Codec != "" {
|
||||
GoLog("[Download] Actual quality from file: %s %d-bit/%dHz\n", quality.Codec, quality.BitDepth, quality.SampleRate)
|
||||
} else {
|
||||
GoLog("[Download] Actual quality from file: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1101,12 +1154,14 @@ func CleanupConnections() {
|
||||
func ReadFileMetadata(filePath string) (string, error) {
|
||||
lower := strings.ToLower(filePath)
|
||||
isFlac := strings.HasSuffix(lower, ".flac")
|
||||
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac")
|
||||
isM4A := strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".aac")
|
||||
isMp3 := strings.HasSuffix(lower, ".mp3")
|
||||
isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
|
||||
isApe := strings.HasSuffix(lower, ".ape")
|
||||
isWv := strings.HasSuffix(lower, ".wv")
|
||||
isMpc := strings.HasSuffix(lower, ".mpc")
|
||||
isWav := strings.HasSuffix(lower, ".wav")
|
||||
isAiff := strings.HasSuffix(lower, ".aiff") || strings.HasSuffix(lower, ".aif") || strings.HasSuffix(lower, ".aifc")
|
||||
|
||||
result := map[string]interface{}{
|
||||
"title": "",
|
||||
@@ -1126,9 +1181,13 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
"composer": "",
|
||||
"comment": "",
|
||||
"duration": 0,
|
||||
"format": "",
|
||||
"audio_codec": "",
|
||||
}
|
||||
|
||||
if isFlac {
|
||||
result["format"] = "flac"
|
||||
result["audio_codec"] = "flac"
|
||||
metadata, err := ReadMetadata(filePath)
|
||||
if err != nil {
|
||||
// File may have wrong extension (e.g. opus saved as .flac).
|
||||
@@ -1161,6 +1220,8 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
result["bitrate"] = quality.Bitrate / 1000
|
||||
}
|
||||
}
|
||||
result["format"] = "opus"
|
||||
result["audio_codec"] = "opus"
|
||||
} else {
|
||||
return "", fmt.Errorf("failed to read metadata: %w", err)
|
||||
}
|
||||
@@ -1190,12 +1251,16 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
if qualityErr == nil {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
if quality.Codec != "" {
|
||||
result["audio_codec"] = quality.Codec
|
||||
}
|
||||
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if isM4A {
|
||||
result["format"] = "m4a"
|
||||
meta, err := ReadM4ATags(filePath)
|
||||
if err == nil && meta != nil {
|
||||
result["title"] = meta.Title
|
||||
@@ -1227,8 +1292,17 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
result["bit_depth"] = quality.BitDepth
|
||||
result["sample_rate"] = quality.SampleRate
|
||||
result["duration"] = quality.Duration
|
||||
result["audio_codec"] = quality.Codec
|
||||
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
|
||||
result["format"] = format
|
||||
}
|
||||
if quality.Bitrate > 0 && !isLosslessLibraryFormat(fmt.Sprint(result["format"])) {
|
||||
result["bitrate"] = quality.Bitrate
|
||||
}
|
||||
}
|
||||
} else if isMp3 {
|
||||
result["format"] = "mp3"
|
||||
result["audio_codec"] = "mp3"
|
||||
meta, err := ReadID3Tags(filePath)
|
||||
if err == nil && meta != nil {
|
||||
result["title"] = meta.Title
|
||||
@@ -1265,6 +1339,8 @@ func ReadFileMetadata(filePath string) (string, error) {
|
||||
}
|
||||
}
|
||||
} else if isOgg {
|
||||
result["format"] = "opus"
|
||||
result["audio_codec"] = "opus"
|
||||
meta, err := ReadOggVorbisComments(filePath)
|
||||
if err == nil && meta != nil {
|
||||
result["title"] = meta.Title
|
||||
@@ -1300,6 +1376,8 @@ 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 {
|
||||
@@ -1330,6 +1408,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)
|
||||
}
|
||||
@@ -1398,8 +1521,23 @@ 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)) {
|
||||
if err := EditM4AReplayGain(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write M4A metadata: %w", err)
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"method": "native_m4a_replaygain",
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
if isFlac {
|
||||
if err := EditFlacFields(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
|
||||
@@ -1413,6 +1551,24 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// APE/WV/MPC: write APEv2 tags natively
|
||||
if isApeFile {
|
||||
trackNum := 0
|
||||
@@ -1510,19 +1666,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
if isM4AFile && hasOnlyM4AReplayGainFields(fields) {
|
||||
if err := EditM4AReplayGain(filePath, fields); err != nil {
|
||||
return "", fmt.Errorf("failed to write M4A metadata: %w", err)
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"method": "native_m4a_replaygain",
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(resp)
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"success": true,
|
||||
"method": "ffmpeg",
|
||||
@@ -1532,6 +1675,21 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func isMP4ContainerFile(filePath string) bool {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
header := make([]byte, 12)
|
||||
n, err := f.Read(header)
|
||||
if err != nil || n < 8 {
|
||||
return false
|
||||
}
|
||||
return string(header[4:8]) == "ftyp"
|
||||
}
|
||||
|
||||
func hasOnlyM4AReplayGainFields(fields map[string]string) bool {
|
||||
allowed := map[string]struct{}{
|
||||
"replaygain_track_gain": {},
|
||||
@@ -1660,9 +1818,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,
|
||||
}
|
||||
@@ -1865,6 +2027,11 @@ func normalizeExtensionTrackMetadataMap(
|
||||
"artists": track.Artists,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"album_id": track.AlbumID,
|
||||
"album_url": track.AlbumURL,
|
||||
"artist_id": track.ArtistID,
|
||||
"artist_url": track.ArtistURL,
|
||||
"external_urls": track.ExternalURL,
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": coverURL,
|
||||
"cover_url": coverURL,
|
||||
@@ -2283,37 +2450,7 @@ func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
||||
}
|
||||
|
||||
func errorResponse(msg string) (string, error) {
|
||||
errorType := "unknown"
|
||||
lowerMsg := strings.ToLower(msg)
|
||||
|
||||
if strings.Contains(lowerMsg, "isp blocking") ||
|
||||
strings.Contains(lowerMsg, "try using vpn") ||
|
||||
strings.Contains(lowerMsg, "change dns") {
|
||||
errorType = "isp_blocked"
|
||||
} else if strings.Contains(lowerMsg, "cancel") {
|
||||
errorType = "cancelled"
|
||||
} else if strings.Contains(lowerMsg, "permission") ||
|
||||
strings.Contains(lowerMsg, "operation not permitted") ||
|
||||
strings.Contains(lowerMsg, "access denied") ||
|
||||
strings.Contains(lowerMsg, "failed to create file") ||
|
||||
strings.Contains(lowerMsg, "failed to create directory") {
|
||||
errorType = "permission"
|
||||
} else if strings.Contains(lowerMsg, "not found") ||
|
||||
strings.Contains(lowerMsg, "not available") ||
|
||||
strings.Contains(lowerMsg, "no results") ||
|
||||
strings.Contains(lowerMsg, "track not found") ||
|
||||
strings.Contains(lowerMsg, "all services failed") {
|
||||
errorType = "not_found"
|
||||
} else if strings.Contains(lowerMsg, "rate limit") ||
|
||||
strings.Contains(lowerMsg, "429") ||
|
||||
strings.Contains(lowerMsg, "too many requests") {
|
||||
errorType = "rate_limit"
|
||||
} else if strings.Contains(lowerMsg, "network") ||
|
||||
strings.Contains(lowerMsg, "connection") ||
|
||||
strings.Contains(lowerMsg, "timeout") ||
|
||||
strings.Contains(lowerMsg, "dial") {
|
||||
errorType = "network"
|
||||
}
|
||||
errorType := classifyDownloadErrorType(msg)
|
||||
|
||||
resp := DownloadResponse{
|
||||
Success: false,
|
||||
@@ -2324,6 +2461,41 @@ func errorResponse(msg string) (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func classifyDownloadErrorType(msg string) string {
|
||||
lowerMsg := strings.ToLower(msg)
|
||||
|
||||
if strings.Contains(lowerMsg, "isp blocking") ||
|
||||
strings.Contains(lowerMsg, "try using vpn") ||
|
||||
strings.Contains(lowerMsg, "change dns") {
|
||||
return "isp_blocked"
|
||||
} else if strings.Contains(lowerMsg, "cancel") {
|
||||
return "cancelled"
|
||||
} else if strings.Contains(lowerMsg, "rate limit") ||
|
||||
strings.Contains(lowerMsg, "429") ||
|
||||
strings.Contains(lowerMsg, "too many requests") {
|
||||
return "rate_limit"
|
||||
} else if strings.Contains(lowerMsg, "permission") ||
|
||||
strings.Contains(lowerMsg, "operation not permitted") ||
|
||||
strings.Contains(lowerMsg, "access denied") ||
|
||||
strings.Contains(lowerMsg, "failed to create file") ||
|
||||
strings.Contains(lowerMsg, "failed to create directory") {
|
||||
return "permission"
|
||||
} else if strings.Contains(lowerMsg, "not found") ||
|
||||
strings.Contains(lowerMsg, "not available") ||
|
||||
strings.Contains(lowerMsg, "no results") ||
|
||||
strings.Contains(lowerMsg, "track not found") ||
|
||||
strings.Contains(lowerMsg, "all services failed") {
|
||||
return "not_found"
|
||||
} else if strings.Contains(lowerMsg, "network") ||
|
||||
strings.Contains(lowerMsg, "connection") ||
|
||||
strings.Contains(lowerMsg, "timeout") ||
|
||||
strings.Contains(lowerMsg, "dial") {
|
||||
return "network"
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
|
||||
if coverURL == "" {
|
||||
return fmt.Errorf("no cover URL provided")
|
||||
@@ -2670,7 +2842,9 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
metadata.ISRC = req.ISRC
|
||||
}
|
||||
if req.shouldUpdateField("lyrics") {
|
||||
metadata.Lyrics = lyricsLRC
|
||||
if req.lyricsEmbedEnabled() {
|
||||
metadata.Lyrics = lyricsLRC
|
||||
}
|
||||
}
|
||||
if req.shouldUpdateField("extra") {
|
||||
metadata.Genre = req.Genre
|
||||
@@ -2705,6 +2879,11 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
"method": "native",
|
||||
"success": true,
|
||||
"enriched_metadata": enrichedMeta,
|
||||
"lyrics": lyricsLRC,
|
||||
"write_external_lrc": req.EmbedLyrics &&
|
||||
req.shouldUpdateField("lyrics") &&
|
||||
req.lyricsSidecarEnabled() &&
|
||||
strings.TrimSpace(lyricsLRC) != "",
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(result)
|
||||
return string(jsonBytes), nil
|
||||
@@ -2720,6 +2899,10 @@ func ReEnrichFile(requestJSON string) (string, error) {
|
||||
"lyrics": lyricsLRC,
|
||||
"enriched_metadata": enrichedMeta,
|
||||
"metadata": ffmpegMetadata,
|
||||
"write_external_lrc": req.EmbedLyrics &&
|
||||
req.shouldUpdateField("lyrics") &&
|
||||
req.lyricsSidecarEnabled() &&
|
||||
strings.TrimSpace(lyricsLRC) != "",
|
||||
}
|
||||
|
||||
jsonBytes, _ := json.Marshal(result)
|
||||
|
||||
@@ -11,6 +11,26 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDownloadErrorClassificationPrioritizesRateLimit(t *testing.T) {
|
||||
got := classifyDownloadErrorType("All providers failed. Last error: HTTP status 429: too many requests")
|
||||
if got != "rate_limit" {
|
||||
t.Fatalf("expected rate_limit, got %q", got)
|
||||
}
|
||||
|
||||
responseJSON, err := errorResponse("All services failed. Last error: rate limit exceeded")
|
||||
if err != nil {
|
||||
t.Fatalf("errorResponse returned error: %v", err)
|
||||
}
|
||||
|
||||
var response DownloadResponse
|
||||
if err := json.Unmarshal([]byte(responseJSON), &response); err != nil {
|
||||
t.Fatalf("invalid response JSON: %v", err)
|
||||
}
|
||||
if response.ErrorType != "rate_limit" {
|
||||
t.Fatalf("expected rate_limit response, got %q", response.ErrorType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dataDir := filepath.Join(dir, "data")
|
||||
@@ -85,6 +105,14 @@ func TestExportsJSONWrappersAndExtensionManagerSurface(t *testing.T) {
|
||||
if response, err := EditFileMetadata(filepath.Join(dir, "edit.mp3"), editJSON); err != nil || !strings.Contains(response, "ffmpeg") {
|
||||
t.Fatalf("EditFileMetadata ffmpeg = %q/%v", response, err)
|
||||
}
|
||||
misnamedM4APath := filepath.Join(dir, "misnamed.flac")
|
||||
if err := os.WriteFile(misnamedM4APath, buildM4AFileWithIlst(buildM4ATextTag("\xa9nam", "Misnamed"), true), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
replayGainJSON := `{"replaygain_track_gain":"-1 dB","replaygain_track_peak":"0.9"}`
|
||||
if response, err := EditFileMetadata(misnamedM4APath, replayGainJSON); err != nil || !strings.Contains(response, "native_m4a_replaygain") {
|
||||
t.Fatalf("EditFileMetadata misnamed m4a replaygain = %q/%v", response, err)
|
||||
}
|
||||
if _, err := EditFileMetadata(apePath, `not-json`); err == nil {
|
||||
t.Fatal("expected invalid metadata JSON")
|
||||
}
|
||||
|
||||
@@ -407,6 +407,90 @@ func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectBestReEnrichTrackRejectsMismatchedSearchResults(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Song Title",
|
||||
ArtistName: "Artist Name",
|
||||
AlbumName: "Album Name",
|
||||
DurationMs: 180000,
|
||||
}
|
||||
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "wrong-rich-metadata",
|
||||
Name: "Different Song",
|
||||
Artists: "Different Artist",
|
||||
AlbumName: "Album Name",
|
||||
DurationMS: 180000,
|
||||
ReleaseDate: "2024-03-09",
|
||||
TrackNumber: 4,
|
||||
DiscNumber: 1,
|
||||
ISRC: "WRONG1234567",
|
||||
ProviderID: "deezer",
|
||||
},
|
||||
}
|
||||
|
||||
if best := selectBestReEnrichTrack(req, tracks); best != nil {
|
||||
t.Fatalf("selected track = %q, want no match", best.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectBestReEnrichTrackAllowsExactISRCDespiteMetadataMismatch(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Song Title",
|
||||
ArtistName: "Artist Name",
|
||||
ISRC: "USRC17607839",
|
||||
DurationMs: 999999000,
|
||||
}
|
||||
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "same-isrc",
|
||||
Name: "Different Song",
|
||||
Artists: "Different Artist",
|
||||
DurationMS: 180000,
|
||||
ISRC: "USRC17607839",
|
||||
ProviderID: "deezer",
|
||||
},
|
||||
}
|
||||
|
||||
best := selectBestReEnrichTrack(req, tracks)
|
||||
if best == nil {
|
||||
t.Fatal("expected exact ISRC candidate to be selected")
|
||||
}
|
||||
if best.ID != "same-isrc" {
|
||||
t.Fatalf("selected track = %q, want exact ISRC candidate", best.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectBestReEnrichTrackPlaceholderFallsBackToAlbum(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Unknown Title",
|
||||
ArtistName: "Unknown Artist",
|
||||
AlbumName: "Harry Styles",
|
||||
DurationMs: 180000,
|
||||
}
|
||||
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "album-match",
|
||||
Name: "Sign of the Times",
|
||||
Artists: "Harry Styles",
|
||||
AlbumName: "Harry Styles",
|
||||
DurationMS: 180000,
|
||||
ProviderID: "deezer",
|
||||
},
|
||||
}
|
||||
|
||||
best := selectBestReEnrichTrack(req, tracks)
|
||||
if best == nil {
|
||||
t.Fatal("expected album-matching candidate to be selected when title/artist are placeholders")
|
||||
}
|
||||
if best.ID != "album-match" {
|
||||
t.Fatalf("selected track = %q, want album-match", best.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Song",
|
||||
|
||||
@@ -8,12 +8,14 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
extensionHealthDefaultTimeout = 4 * time.Second
|
||||
extensionHealthMaxBodyBytes = 64 * 1024
|
||||
extensionHealthDefaultCache = 60 * time.Second
|
||||
)
|
||||
|
||||
type ExtensionHealthResult struct {
|
||||
@@ -38,6 +40,16 @@ type ExtensionHealthCheckResult struct {
|
||||
CheckedAt string `json:"checked_at"`
|
||||
}
|
||||
|
||||
type cachedExtensionHealthResult struct {
|
||||
result ExtensionHealthResult
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
extensionHealthCacheMu sync.Mutex
|
||||
extensionHealthCache = map[string]cachedExtensionHealthResult{}
|
||||
)
|
||||
|
||||
func CheckExtensionHealthJSON(extensionID string) (string, error) {
|
||||
manager := getExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
@@ -53,6 +65,38 @@ func CheckExtensionHealthJSON(extensionID string) (string, error) {
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult {
|
||||
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
|
||||
return CheckExtensionHealth(ext)
|
||||
}
|
||||
|
||||
cacheKey := strings.TrimSpace(ext.ID)
|
||||
if cacheKey == "" {
|
||||
return CheckExtensionHealth(ext)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
extensionHealthCacheMu.Lock()
|
||||
cached, ok := extensionHealthCache[cacheKey]
|
||||
if ok && now.Before(cached.expiresAt) {
|
||||
extensionHealthCacheMu.Unlock()
|
||||
return cached.result
|
||||
}
|
||||
extensionHealthCacheMu.Unlock()
|
||||
|
||||
result := CheckExtensionHealth(ext)
|
||||
ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth)
|
||||
|
||||
extensionHealthCacheMu.Lock()
|
||||
extensionHealthCache[cacheKey] = cachedExtensionHealthResult{
|
||||
result: result,
|
||||
expiresAt: now.Add(ttl),
|
||||
}
|
||||
extensionHealthCacheMu.Unlock()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
result := ExtensionHealthResult{
|
||||
@@ -98,6 +142,20 @@ func CheckExtensionHealth(ext *loadedExtension) ExtensionHealthResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func extensionHealthCacheTTL(checks []ExtensionHealthCheck) time.Duration {
|
||||
ttl := extensionHealthDefaultCache
|
||||
for _, check := range checks {
|
||||
if check.CacheTTLSeconds <= 0 {
|
||||
continue
|
||||
}
|
||||
checkTTL := time.Duration(check.CacheTTLSeconds) * time.Second
|
||||
if checkTTL < ttl {
|
||||
ttl = checkTTL
|
||||
}
|
||||
}
|
||||
return ttl
|
||||
}
|
||||
|
||||
func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthCheck) ExtensionHealthCheckResult {
|
||||
method := strings.ToUpper(strings.TrimSpace(check.Method))
|
||||
if method == "" {
|
||||
|
||||
@@ -118,7 +118,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,6 +160,12 @@ func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
}
|
||||
|
||||
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
|
||||
m.mutationMu.Lock()
|
||||
defer m.mutationMu.Unlock()
|
||||
return m.loadExtensionFromFileLocked(filePath)
|
||||
}
|
||||
|
||||
func (m *extensionManager) loadExtensionFromFileLocked(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")
|
||||
}
|
||||
@@ -212,7 +222,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 {
|
||||
@@ -367,8 +377,8 @@ func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensio
|
||||
jar, _ := newSimpleCookieJar()
|
||||
runtime.cookieJar = jar
|
||||
}
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second))
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout)
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, runtime.cookieJar, extensionHTTPTimeout(ext, 30*time.Second), true)
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, runtime.cookieJar, DownloadTimeout, false)
|
||||
runtime.RegisterAPIs(vm)
|
||||
runtime.RegisterGoBackendAPIs(vm)
|
||||
|
||||
@@ -736,6 +746,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,6 +769,12 @@ func (m *extensionManager) RemoveExtension(extensionID string) error {
|
||||
|
||||
// Only allows upgrades (new version > current version), not downgrades
|
||||
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
|
||||
m.mutationMu.Lock()
|
||||
defer m.mutationMu.Unlock()
|
||||
return m.upgradeExtensionLocked(filePath)
|
||||
}
|
||||
|
||||
func (m *extensionManager) upgradeExtensionLocked(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")
|
||||
}
|
||||
|
||||
@@ -25,9 +25,10 @@ const (
|
||||
)
|
||||
|
||||
type ExtensionPermissions struct {
|
||||
Network []string `json:"network"`
|
||||
Storage bool `json:"storage"`
|
||||
File bool `json:"file"`
|
||||
Network []string `json:"network"`
|
||||
Storage bool `json:"storage"`
|
||||
File bool `json:"file"`
|
||||
AllowHTTP bool `json:"allowHttp,omitempty"`
|
||||
}
|
||||
|
||||
type ExtensionSetting struct {
|
||||
|
||||
@@ -22,6 +22,11 @@ type ExtTrackMetadata struct {
|
||||
Artists string `json:"artists"`
|
||||
AlbumName string `json:"album_name"`
|
||||
AlbumArtist string `json:"album_artist,omitempty"`
|
||||
AlbumID string `json:"album_id,omitempty"`
|
||||
AlbumURL string `json:"album_url,omitempty"`
|
||||
ArtistID string `json:"artist_id,omitempty"`
|
||||
ArtistURL string `json:"artist_url,omitempty"`
|
||||
ExternalURL string `json:"external_urls,omitempty"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
Images string `json:"images,omitempty"`
|
||||
@@ -236,6 +241,7 @@ func normalizeExtensionDownloadResult(result *ExtDownloadResult) (DownloadResult
|
||||
FilePath: strings.TrimSpace(result.FilePath),
|
||||
BitDepth: result.BitDepth,
|
||||
SampleRate: result.SampleRate,
|
||||
AudioCodec: strings.TrimSpace(result.AudioCodec),
|
||||
Title: result.Title,
|
||||
Artist: result.Artist,
|
||||
Album: result.Album,
|
||||
@@ -376,6 +382,64 @@ func shouldStopProviderFallback(availability *ExtAvailabilityResult) bool {
|
||||
return availability != nil && availability.SkipFallback
|
||||
}
|
||||
|
||||
func fallbackRuntimeHealthStatus(ext *loadedExtension) string {
|
||||
if ext == nil || ext.Manifest == nil || len(ext.Manifest.ServiceHealth) == 0 {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
status := strings.ToLower(strings.TrimSpace(CheckExtensionHealthCached(ext).Status))
|
||||
switch status {
|
||||
case "online", "degraded", "offline":
|
||||
return status
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func prioritizeFallbackProvidersByHealth(priority []string, extManager *extensionManager, sourceProvider string) []string {
|
||||
if len(priority) == 0 || extManager == nil {
|
||||
return priority
|
||||
}
|
||||
|
||||
online := make([]string, 0, len(priority))
|
||||
degraded := make([]string, 0, len(priority))
|
||||
unknown := make([]string, 0, len(priority))
|
||||
|
||||
for _, rawProviderID := range priority {
|
||||
providerID := strings.TrimSpace(rawProviderID)
|
||||
if providerID == "" {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(providerID, sourceProvider) || !isExtensionFallbackAllowed(providerID) {
|
||||
unknown = append(unknown, providerID)
|
||||
continue
|
||||
}
|
||||
|
||||
ext, err := extManager.GetExtension(providerID)
|
||||
if err != nil || ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil || !ext.Manifest.IsDownloadProvider() {
|
||||
unknown = append(unknown, providerID)
|
||||
continue
|
||||
}
|
||||
|
||||
switch fallbackRuntimeHealthStatus(ext) {
|
||||
case "online":
|
||||
online = append(online, providerID)
|
||||
case "degraded":
|
||||
degraded = append(degraded, providerID)
|
||||
case "offline":
|
||||
GoLog("[DownloadWithExtensionFallback] Skipping extension provider %s (service health offline)\n", providerID)
|
||||
default:
|
||||
unknown = append(unknown, providerID)
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(online)+len(degraded)+len(unknown))
|
||||
result = append(result, online...)
|
||||
result = append(result, degraded...)
|
||||
result = append(result, unknown...)
|
||||
return result
|
||||
}
|
||||
|
||||
func resolveExtensionAvailabilityReason(availability *ExtAvailabilityResult, err error) string {
|
||||
if availability != nil {
|
||||
if reason := strings.TrimSpace(availability.Reason); reason != "" {
|
||||
@@ -390,10 +454,14 @@ func resolveExtensionAvailabilityReason(availability *ExtAvailabilityResult, err
|
||||
|
||||
func buildExtensionFallbackStoppedResponse(providerID string, availability *ExtAvailabilityResult, err error) *DownloadResponse {
|
||||
reason := resolveExtensionAvailabilityReason(availability, err)
|
||||
errorType := classifyDownloadErrorType(reason)
|
||||
if errorType == "unknown" {
|
||||
errorType = "extension_error"
|
||||
}
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("Fallback stopped by %s: %s", providerID, reason),
|
||||
ErrorType: "extension_error",
|
||||
ErrorType: errorType,
|
||||
Service: providerID,
|
||||
}
|
||||
}
|
||||
@@ -420,6 +488,7 @@ type ExtDownloadResult struct {
|
||||
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"`
|
||||
|
||||
@@ -678,6 +747,11 @@ func parseExtensionTrackValue(vm *goja.Runtime, value goja.Value) ExtTrackMetada
|
||||
Artists: gojaObjectString(obj, "artists"),
|
||||
AlbumName: gojaObjectString(obj, "album_name", "albumName"),
|
||||
AlbumArtist: gojaObjectString(obj, "album_artist", "albumArtist"),
|
||||
AlbumID: gojaObjectString(obj, "album_id", "albumId"),
|
||||
AlbumURL: gojaObjectString(obj, "album_url", "albumUrl"),
|
||||
ArtistID: gojaObjectString(obj, "artist_id", "artistId"),
|
||||
ArtistURL: gojaObjectString(obj, "artist_url", "artistUrl"),
|
||||
ExternalURL: gojaObjectString(obj, "external_urls", "externalUrls", "external_url", "externalUrl", "url"),
|
||||
DurationMS: gojaObjectInt(obj, "duration_ms", "durationMs"),
|
||||
CoverURL: gojaObjectString(obj, "cover_url", "coverUrl"),
|
||||
Images: gojaObjectString(obj, "images"),
|
||||
@@ -873,6 +947,7 @@ func parseExtensionDownloadResultValue(vm *goja.Runtime, value goja.Value) ExtDo
|
||||
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"),
|
||||
@@ -1783,7 +1858,9 @@ func isRetiredBuiltInDownloadProvider(providerID string) bool {
|
||||
}
|
||||
switch normalized {
|
||||
case "deezer", "qobuz", "tidal":
|
||||
return true
|
||||
return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool {
|
||||
return manifest.IsDownloadProvider()
|
||||
})
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -1796,12 +1873,36 @@ func isRetiredBuiltInMetadataProvider(providerID string) bool {
|
||||
}
|
||||
switch normalized {
|
||||
case "deezer", "spotify", "qobuz", "tidal":
|
||||
return true
|
||||
return !hasEnabledExtensionProvider(normalized, func(manifest *ExtensionManifest) bool {
|
||||
return manifest.IsMetadataProvider()
|
||||
})
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func hasEnabledExtensionProvider(providerID string, matches func(*ExtensionManifest) bool) bool {
|
||||
if providerID == "" || matches == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
manager := getExtensionManager()
|
||||
manager.mu.RLock()
|
||||
defer manager.mu.RUnlock()
|
||||
|
||||
for id, ext := range manager.extensions {
|
||||
if !strings.EqualFold(strings.TrimSpace(id), providerID) {
|
||||
continue
|
||||
}
|
||||
if ext == nil || !ext.Enabled || ext.Error != "" || ext.Manifest == nil {
|
||||
return false
|
||||
}
|
||||
return matches(ext.Manifest)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func SetExtensionFallbackProviderIDs(providerIDs []string) {
|
||||
extensionFallbackProviderIDsMu.Lock()
|
||||
defer extensionFallbackProviderIDsMu.Unlock()
|
||||
@@ -2371,6 +2472,8 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
}
|
||||
|
||||
priority = prioritizeFallbackProvidersByHealth(priority, extManager, req.Source)
|
||||
|
||||
for _, providerID := range priority {
|
||||
if isDownloadCancelled(req.ItemID) {
|
||||
return nil, ErrDownloadCancelled
|
||||
@@ -2380,11 +2483,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
|
||||
}
|
||||
@@ -2427,7 +2532,16 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
StartItemProgress(req.ItemID)
|
||||
}
|
||||
|
||||
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, req.ItemID, func(percent int) {
|
||||
// Fallback provider: request its own highest quality, not the
|
||||
// source provider's quality token.
|
||||
fallbackQuality := req.Quality
|
||||
if len(ext.Manifest.QualityOptions) > 0 {
|
||||
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 {
|
||||
@@ -2516,10 +2630,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
|
||||
if lastErr != nil {
|
||||
errorType := classifyDownloadErrorType(lastErr.Error())
|
||||
if errorType == "unknown" {
|
||||
errorType = "not_found"
|
||||
}
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "All providers failed. Last error: " + lastErr.Error(),
|
||||
ErrorType: "not_found",
|
||||
ErrorType: errorType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -2554,9 +2672,10 @@ func buildOutputPath(req DownloadRequest) string {
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||
if filename == "" {
|
||||
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
|
||||
if strings.TrimSpace(filename) == "" {
|
||||
filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)
|
||||
}
|
||||
filename = sanitizeFilename(filename)
|
||||
|
||||
ext := strings.TrimSpace(req.OutputExt)
|
||||
if ext == "" {
|
||||
@@ -2612,9 +2731,10 @@ func buildOutputPathForExtension(req DownloadRequest, ext *loadedExtension) stri
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, metadata)
|
||||
if filename == "" {
|
||||
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
|
||||
if strings.TrimSpace(filename) == "" {
|
||||
filename = fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)
|
||||
}
|
||||
filename = sanitizeFilename(filename)
|
||||
|
||||
outputExt := strings.TrimSpace(req.OutputExt)
|
||||
if outputExt == "" {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -92,6 +93,125 @@ func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetProviderPriorityKeepsExtensionNamedLikeRetiredDownloader(t *testing.T) {
|
||||
original := GetProviderPriority()
|
||||
defer SetProviderPriority(original)
|
||||
|
||||
manager := getExtensionManager()
|
||||
ext := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||
ext.ID = "deezer"
|
||||
ext.Manifest.Name = "deezer"
|
||||
|
||||
manager.mu.Lock()
|
||||
previous, hadPrevious := manager.extensions[ext.ID]
|
||||
manager.extensions[ext.ID] = ext
|
||||
manager.mu.Unlock()
|
||||
defer func() {
|
||||
manager.mu.Lock()
|
||||
if hadPrevious {
|
||||
manager.extensions[ext.ID] = previous
|
||||
} else {
|
||||
delete(manager.extensions, ext.ID)
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
}()
|
||||
|
||||
SetProviderPriority([]string{"deezer", "custom-ext"})
|
||||
|
||||
got := GetProviderPriority()
|
||||
want := []string{"deezer", "custom-ext"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("unexpected priority at %d: got %v want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrioritizeFallbackProvidersByHealthPrefersOnlineAndSkipsOffline(t *testing.T) {
|
||||
manager := getExtensionManager()
|
||||
amazon := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||
amazon.ID = "amazon"
|
||||
amazon.Manifest.Name = "amazon"
|
||||
amazon.Manifest.ServiceHealth = []ExtensionHealthCheck{{
|
||||
ID: "main",
|
||||
URL: "://bad",
|
||||
Required: true,
|
||||
}}
|
||||
|
||||
plain := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||
plain.ID = "plain"
|
||||
plain.Manifest.Name = "plain"
|
||||
|
||||
deezer := newTestLoadedExtension(t, ExtensionTypeDownloadProvider)
|
||||
deezer.ID = "deezer"
|
||||
deezer.Manifest.Name = "deezer"
|
||||
deezer.Manifest.ServiceHealth = []ExtensionHealthCheck{{
|
||||
ID: "main",
|
||||
URL: "https://example.test/health",
|
||||
}}
|
||||
|
||||
manager.mu.Lock()
|
||||
previousAmazon, hadAmazon := manager.extensions[amazon.ID]
|
||||
previousPlain, hadPlain := manager.extensions[plain.ID]
|
||||
previousDeezer, hadDeezer := manager.extensions[deezer.ID]
|
||||
manager.extensions[amazon.ID] = amazon
|
||||
manager.extensions[plain.ID] = plain
|
||||
manager.extensions[deezer.ID] = deezer
|
||||
manager.mu.Unlock()
|
||||
defer func() {
|
||||
manager.mu.Lock()
|
||||
if hadAmazon {
|
||||
manager.extensions[amazon.ID] = previousAmazon
|
||||
} else {
|
||||
delete(manager.extensions, amazon.ID)
|
||||
}
|
||||
if hadPlain {
|
||||
manager.extensions[plain.ID] = previousPlain
|
||||
} else {
|
||||
delete(manager.extensions, plain.ID)
|
||||
}
|
||||
if hadDeezer {
|
||||
manager.extensions[deezer.ID] = previousDeezer
|
||||
} else {
|
||||
delete(manager.extensions, deezer.ID)
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
|
||||
extensionHealthCacheMu.Lock()
|
||||
delete(extensionHealthCache, deezer.ID)
|
||||
extensionHealthCacheMu.Unlock()
|
||||
}()
|
||||
|
||||
extensionHealthCacheMu.Lock()
|
||||
extensionHealthCache[deezer.ID] = cachedExtensionHealthResult{
|
||||
result: ExtensionHealthResult{
|
||||
ExtensionID: deezer.ID,
|
||||
Status: "online",
|
||||
CheckedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
expiresAt: time.Now().Add(time.Minute),
|
||||
}
|
||||
extensionHealthCacheMu.Unlock()
|
||||
|
||||
got := prioritizeFallbackProvidersByHealth(
|
||||
[]string{"amazon", "plain", "deezer"},
|
||||
manager,
|
||||
"",
|
||||
)
|
||||
want := []string{"deezer", "plain"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected provider order length: got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("unexpected provider order at %d: got %v want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
|
||||
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
|
||||
if normalized == nil {
|
||||
@@ -286,6 +406,45 @@ func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathSanitizesTemplateFilename(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
outputDir := t.TempDir()
|
||||
outputPath := buildOutputPath(DownloadRequest{
|
||||
TrackName: `Gehra Hua (From "Dhurandhar")`,
|
||||
ArtistName: "Artist",
|
||||
OutputDir: outputDir,
|
||||
OutputExt: ".flac",
|
||||
FilenameFormat: "{artist} - {title}",
|
||||
})
|
||||
|
||||
base := filepath.Base(outputPath)
|
||||
if strings.ContainsAny(base, `<>:"/\|?*`) {
|
||||
t.Fatalf("output filename still contains illegal characters: %q", base)
|
||||
}
|
||||
if strings.Contains(base, `"`) {
|
||||
t.Fatalf("output filename still contains straight double quote: %q", base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathForExtensionSanitizesTemplateFilename(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||
TrackName: `Gehra Hua (From "Dhurandhar")`,
|
||||
ArtistName: "Artist",
|
||||
OutputFD: 123,
|
||||
OutputExt: ".flac",
|
||||
FilenameFormat: "{artist} - {title}",
|
||||
}, ext)
|
||||
|
||||
base := filepath.Base(resolved)
|
||||
if strings.ContainsAny(base, `<>:"/\|?*`) {
|
||||
t.Fatalf("extension output filename still contains illegal characters: %q", base)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldStopProviderFallback(t *testing.T) {
|
||||
if shouldStopProviderFallback(nil) {
|
||||
t.Fatal("nil availability should not stop fallback")
|
||||
|
||||
@@ -140,8 +140,8 @@ func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
|
||||
storageFlushDelay: defaultStorageFlushDelay,
|
||||
}
|
||||
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second))
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second), true)
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout, false)
|
||||
|
||||
return runtime
|
||||
}
|
||||
@@ -247,18 +247,24 @@ func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Re
|
||||
return req.WithContext(initDownloadCancel(itemID))
|
||||
}
|
||||
|
||||
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
||||
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration, compressResponses bool) *http.Client {
|
||||
// Extension sandbox enforces HTTPS-only domains. Do not apply global
|
||||
// allow_http scheme downgrade here, because some extension APIs (e.g.
|
||||
// spotify-web) will redirect http -> https and can end up in 301 loops.
|
||||
// We still reuse sharedTransport so insecure TLS compatibility mode remains effective.
|
||||
// API calls can use response compression for faster metadata/search loads,
|
||||
// while media downloads keep identity transfer semantics for progress/streaming.
|
||||
transport := sharedTransport
|
||||
if compressResponses {
|
||||
transport = extensionAPITransport
|
||||
}
|
||||
client := &http.Client{
|
||||
Transport: sharedTransport,
|
||||
Transport: transport,
|
||||
Timeout: timeout,
|
||||
Jar: jar,
|
||||
}
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if req.URL.Scheme != "https" {
|
||||
if req.URL.Scheme != "https" &&
|
||||
!(req.URL.Scheme == "http" && ext.Manifest.Permissions.AllowHTTP) {
|
||||
GoLog("[Extension:%s] Redirect blocked: non-https scheme '%s'\n", ext.ID, req.URL.Scheme)
|
||||
return fmt.Errorf("redirect blocked: only https is allowed")
|
||||
}
|
||||
@@ -498,6 +504,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,10 @@ func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
if parsedOptions.Mode != "cbc" {
|
||||
switch parsedOptions.Mode {
|
||||
case "cbc", "ctr":
|
||||
// supported
|
||||
default:
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
|
||||
@@ -303,37 +311,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 +378,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
"sample_rate": quality.SampleRate,
|
||||
"total_samples": quality.TotalSamples,
|
||||
"duration": float64(quality.TotalSamples) / float64(quality.SampleRate),
|
||||
"codec": quality.Codec,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
var onProgress goja.Callable
|
||||
var headers map[string]string
|
||||
var chunkedDownload bool
|
||||
trackItemBytes := true
|
||||
var chunkSize int64
|
||||
if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) {
|
||||
optionsObj := call.Arguments[2].Export()
|
||||
@@ -151,6 +152,15 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
onProgress = callable
|
||||
}
|
||||
}
|
||||
if trackBytes, ok := opts["trackItemBytes"]; ok {
|
||||
if v, ok := trackBytes.(bool); ok {
|
||||
trackItemBytes = v
|
||||
}
|
||||
} else if trackBytes, ok := opts["track_item_bytes"]; ok {
|
||||
if v, ok := trackBytes.(bool); ok {
|
||||
trackItemBytes = v
|
||||
}
|
||||
}
|
||||
if chunked, ok := opts["chunked"]; ok {
|
||||
switch v := chunked.(type) {
|
||||
case bool:
|
||||
@@ -194,7 +204,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
|
||||
if chunkedDownload {
|
||||
return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress)
|
||||
return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress, trackItemBytes)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", urlStr, nil)
|
||||
@@ -244,7 +254,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
|
||||
contentLength := resp.ContentLength
|
||||
shouldTrackItemBytes := activeItemID != "" && onProgress == nil
|
||||
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
|
||||
if shouldTrackItemBytes && contentLength > 0 {
|
||||
SetItemBytesTotal(activeItemID, contentLength)
|
||||
}
|
||||
@@ -301,6 +311,14 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
if shouldTrackItemBytes {
|
||||
if contentLength > 0 {
|
||||
SetItemProgress(activeItemID, float64(written)/float64(contentLength), written, contentLength)
|
||||
} else if written > 0 {
|
||||
SetItemBytesReceived(activeItemID, written)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath)
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -313,7 +331,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
// fileDownloadChunked downloads a URL using sequential Range requests.
|
||||
// This is needed for servers (like YouTube's googlevideo CDN) that reject
|
||||
// non-ranged or large-range requests with 403 and require small chunk downloads.
|
||||
func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable) goja.Value {
|
||||
func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable, trackItemBytes bool) goja.Value {
|
||||
// First, get the total content length with a small probe request
|
||||
probeReq, err := http.NewRequest("GET", urlStr, nil)
|
||||
if err != nil {
|
||||
@@ -383,7 +401,7 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
|
||||
SetItemDownloading(activeItemID)
|
||||
}
|
||||
|
||||
shouldTrackItemBytes := activeItemID != "" && onProgress == nil
|
||||
shouldTrackItemBytes := activeItemID != "" && trackItemBytes
|
||||
if shouldTrackItemBytes && totalSize > 0 {
|
||||
SetItemBytesTotal(activeItemID, totalSize)
|
||||
}
|
||||
@@ -526,6 +544,14 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full
|
||||
}
|
||||
}
|
||||
|
||||
if shouldTrackItemBytes {
|
||||
if totalSize > 0 {
|
||||
SetItemProgress(activeItemID, float64(totalWritten)/float64(totalSize), totalWritten, totalSize)
|
||||
} else if totalWritten > 0 {
|
||||
SetItemBytesReceived(activeItemID, totalWritten)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension:%s] Chunked download complete: %d bytes to %s\n", r.extensionID, totalWritten, fullPath)
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
@@ -637,7 +663,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{}{
|
||||
@@ -690,6 +715,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{}{
|
||||
@@ -707,7 +746,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{}{
|
||||
|
||||
@@ -44,7 +44,8 @@ func (r *extensionRuntime) validateDomain(urlStr string) error {
|
||||
if parsed.Scheme == "" {
|
||||
return fmt.Errorf("invalid URL: scheme is required")
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
if parsed.Scheme != "https" &&
|
||||
!(parsed.Scheme == "http" && r.manifest.Permissions.AllowHTTP) {
|
||||
return fmt.Errorf("network access denied: only https is allowed")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
|
||||
@@ -415,6 +415,7 @@ func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
"sampleRate": quality.SampleRate,
|
||||
"totalSamples": quality.TotalSamples,
|
||||
"duration": quality.Duration,
|
||||
"codec": quality.Codec,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -144,6 +144,15 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
if err := runtime.validateDomain("https://notallowed.com/path"); err == nil {
|
||||
t.Error("Expected notallowed.com to be denied")
|
||||
}
|
||||
|
||||
if err := runtime.validateDomain("http://api.allowed.com/path"); err == nil {
|
||||
t.Error("Expected http URL to be denied without allowHttp")
|
||||
}
|
||||
|
||||
ext.Manifest.Permissions.AllowHTTP = true
|
||||
if err := runtime.validateDomain("http://api.allowed.com/path"); err != nil {
|
||||
t.Errorf("Expected http URL to be allowed with allowHttp, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
|
||||
@@ -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-20260607120635-348e6bea910d
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2
|
||||
github.com/go-flac/go-flac/v2 v2.0.4
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/text v0.36.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/mobile v0.0.0-20260602190626-68735029466e
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/text v0.38.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
)
|
||||
|
||||
@@ -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/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
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.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/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.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d h1:xbM5U2EvWKkHxzEQJ2DEn20FwolWZahuTnVHr6WL3Q4=
|
||||
github.com/dop251/goja v0.0.0-20260607120635-348e6bea910d/go.mod h1:Sc+QOu1WruvaaeT/cxFez/pXHpI9ZDjg/E8QNfSVveI=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
|
||||
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
|
||||
@@ -16,12 +16,14 @@ github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sY
|
||||
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
|
||||
github.com/go-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-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
|
||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE=
|
||||
github.com/google/pprof v0.0.0-20260604005048-7023385849c0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||
@@ -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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b h1:Qt2eaXcZ8x20iAcoZ6AceeMMtnjuPHvC51KRCH1DKSQ=
|
||||
golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b/go.mod h1:5Fu78lew5ucMXt8w2KYcwvxu2rkC/liHzUvaoiI+H/M=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/mobile v0.0.0-20260602190626-68735029466e h1:YxPXu/HWDTcSSrzSX+sCltsfcNCa/ZYVG43oslMouNU=
|
||||
golang.org/x/mobile v0.0.0-20260602190626-68735029466e/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -77,6 +77,26 @@ var sharedTransport = &http.Transport{
|
||||
WriteBufferSize: 64 * 1024,
|
||||
ReadBufferSize: 64 * 1024,
|
||||
DisableCompression: true,
|
||||
TLSClientConfig: newTLSCompatibilityConfig(false),
|
||||
}
|
||||
|
||||
var extensionAPITransport = &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 30 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
MaxConnsPerHost: 20,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
DisableKeepAlives: false,
|
||||
ForceAttemptHTTP2: true,
|
||||
WriteBufferSize: 64 * 1024,
|
||||
ReadBufferSize: 64 * 1024,
|
||||
DisableCompression: false,
|
||||
TLSClientConfig: newTLSCompatibilityConfig(false),
|
||||
}
|
||||
|
||||
var metadataTransport = &http.Transport{
|
||||
@@ -95,6 +115,7 @@ var metadataTransport = &http.Transport{
|
||||
WriteBufferSize: 32 * 1024,
|
||||
ReadBufferSize: 32 * 1024,
|
||||
DisableCompression: true,
|
||||
TLSClientConfig: newTLSCompatibilityConfig(false),
|
||||
}
|
||||
|
||||
var sharedClient = &http.Client{
|
||||
@@ -131,6 +152,7 @@ func GetDownloadClient() *http.Client {
|
||||
|
||||
func CloseIdleConnections() {
|
||||
sharedTransport.CloseIdleConnections()
|
||||
extensionAPITransport.CloseIdleConnections()
|
||||
metadataTransport.CloseIdleConnections()
|
||||
}
|
||||
|
||||
@@ -143,6 +165,7 @@ func SetNetworkCompatibilityOptions(allowHTTP, insecureTLS bool) {
|
||||
networkCompatibilityMu.Unlock()
|
||||
|
||||
applyTLSCompatibility(sharedTransport, insecureTLS)
|
||||
applyTLSCompatibility(extensionAPITransport, insecureTLS)
|
||||
applyTLSCompatibility(metadataTransport, insecureTLS)
|
||||
CloseIdleConnections()
|
||||
|
||||
@@ -156,17 +179,7 @@ func GetNetworkCompatibilityOptions() NetworkCompatibilityOptions {
|
||||
}
|
||||
|
||||
func applyTLSCompatibility(transport *http.Transport, insecureTLS bool) {
|
||||
if insecureTLS {
|
||||
cfg := &tls.Config{InsecureSkipVerify: true}
|
||||
if transport.TLSClientConfig != nil {
|
||||
cfg = transport.TLSClientConfig.Clone()
|
||||
cfg.InsecureSkipVerify = true
|
||||
}
|
||||
transport.TLSClientConfig = cfg
|
||||
return
|
||||
}
|
||||
|
||||
transport.TLSClientConfig = nil
|
||||
transport.TLSClientConfig = newTLSCompatibilityConfig(insecureTLS)
|
||||
}
|
||||
|
||||
type compatibilityTransport struct {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -25,11 +27,34 @@ func TestHTTPUtilityHelpers(t *testing.T) {
|
||||
if GetSharedClient() == nil || GetDownloadClient() == nil {
|
||||
t.Fatal("expected shared clients")
|
||||
}
|
||||
if sharedTransport.TLSClientConfig == nil || sharedTransport.TLSClientConfig.RootCAs == nil {
|
||||
t.Fatal("expected supplemental TLS root pool")
|
||||
}
|
||||
block, _ := pem.Decode([]byte(isrgRootX2PEM))
|
||||
if block == nil {
|
||||
t.Fatal("failed to decode ISRG Root X2")
|
||||
}
|
||||
rootX2, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse ISRG Root X2: %v", err)
|
||||
}
|
||||
if _, err := rootX2.Verify(x509.VerifyOptions{
|
||||
Roots: supplementalRootCAs(),
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
}); err != nil {
|
||||
t.Fatalf("ISRG Root X2 should verify with supplemental roots: %v", err)
|
||||
}
|
||||
SetNetworkCompatibilityOptions(true, true)
|
||||
if opts := GetNetworkCompatibilityOptions(); !opts.AllowHTTP || !opts.InsecureTLS {
|
||||
t.Fatalf("network opts = %#v", opts)
|
||||
}
|
||||
if !sharedTransport.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatal("expected insecure TLS config to be applied")
|
||||
}
|
||||
SetNetworkCompatibilityOptions(false, false)
|
||||
if sharedTransport.TLSClientConfig == nil || sharedTransport.TLSClientConfig.InsecureSkipVerify {
|
||||
t.Fatal("expected secure TLS config to be restored")
|
||||
}
|
||||
if !canFallbackToHTTP(&http.Request{Method: http.MethodGet}) {
|
||||
t.Fatal("GET should fallback")
|
||||
}
|
||||
|
||||
@@ -42,9 +42,12 @@ func (t *utlsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
opts := GetNetworkCompatibilityOptions()
|
||||
tlsConn := utls.UClient(conn, &utls.Config{
|
||||
ServerName: host,
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
RootCAs: supplementalRootCAs(),
|
||||
InsecureSkipVerify: opts.InsecureTLS,
|
||||
ServerName: host,
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
}, utls.HelloChrome_Auto)
|
||||
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
|
||||
@@ -68,12 +68,17 @@ var (
|
||||
var supportedAudioFormats = map[string]bool{
|
||||
".flac": true,
|
||||
".m4a": true,
|
||||
".mp4": true,
|
||||
".aac": true,
|
||||
".mp3": true,
|
||||
".opus": true,
|
||||
".ogg": true,
|
||||
".ape": true,
|
||||
".wv": true,
|
||||
".mpc": true,
|
||||
".wav": true,
|
||||
".aiff": true,
|
||||
".aif": true,
|
||||
".cue": true,
|
||||
}
|
||||
|
||||
@@ -87,6 +92,19 @@ type scannedCueFileInfo struct {
|
||||
audioPath string
|
||||
}
|
||||
|
||||
func isLibraryStagingFile(path string) bool {
|
||||
name := strings.ToLower(filepath.Base(path))
|
||||
if strings.HasSuffix(name, ".partial") {
|
||||
return true
|
||||
}
|
||||
for ext := range supportedAudioFormats {
|
||||
if strings.HasSuffix(name, ".partial"+ext) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) {
|
||||
var files []libraryAudioFileInfo
|
||||
|
||||
@@ -104,6 +122,9 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if isLibraryStagingFile(path) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if !supportedAudioFormats[ext] {
|
||||
@@ -314,7 +335,7 @@ func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displ
|
||||
switch ext {
|
||||
case ".flac":
|
||||
return scanFLACFile(filePath, result, displayNameHint)
|
||||
case ".m4a":
|
||||
case ".m4a", ".mp4", ".aac":
|
||||
return scanM4AFile(filePath, result, displayNameHint)
|
||||
case ".mp3":
|
||||
return scanMP3File(filePath, result, displayNameHint)
|
||||
@@ -322,6 +343,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)
|
||||
}
|
||||
@@ -394,7 +419,6 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
metadata, err := ReadM4ATags(filePath)
|
||||
if err != nil {
|
||||
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
|
||||
if metadata != nil {
|
||||
@@ -421,12 +445,54 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
if err == nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
result.Duration = quality.Duration
|
||||
if quality.Bitrate > 0 {
|
||||
result.Bitrate = quality.Bitrate
|
||||
}
|
||||
if format := libraryFormatForM4ACodec(quality.Codec); format != "" {
|
||||
result.Format = format
|
||||
if isLosslessLibraryFormat(format) {
|
||||
result.Bitrate = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if metadata == nil {
|
||||
return scanFromFilename(filePath, displayNameHint, result)
|
||||
}
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func libraryFormatForM4ACodec(codec string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(codec)) {
|
||||
case "flac":
|
||||
return "flac"
|
||||
case "alac":
|
||||
return "alac"
|
||||
case "eac3", "ec-3":
|
||||
return "eac3"
|
||||
case "ac3", "ac-3":
|
||||
return "ac3"
|
||||
case "ac4", "ac-4":
|
||||
return "ac4"
|
||||
case "aac", "mp4a":
|
||||
return "m4a"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isLosslessLibraryFormat(format string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "flac", "alac", "wav", "aiff", "aif", "aifc":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
metadata, err := ReadID3Tags(filePath)
|
||||
if err != nil {
|
||||
|
||||
@@ -42,6 +42,14 @@ func TestLibraryScanFullIncrementalAndMetadataFallbacks(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(albumDir, "ignored.txt"), []byte("ignore"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyPartialPath := filepath.Join(albumDir, "Artist - Song.partial.flac")
|
||||
if err := os.WriteFile(legacyPartialPath, []byte("partial flac"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newPartialPath := filepath.Join(albumDir, "Artist - Song.flac.partial")
|
||||
if err := os.WriteFile(newPartialPath, []byte("partial flac"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
files, err := collectLibraryAudioFiles(dir, make(chan struct{}))
|
||||
if err != nil {
|
||||
@@ -50,6 +58,11 @@ func TestLibraryScanFullIncrementalAndMetadataFallbacks(t *testing.T) {
|
||||
if len(files) < 4 {
|
||||
t.Fatalf("files = %#v", files)
|
||||
}
|
||||
for _, file := range files {
|
||||
if file.path == legacyPartialPath || file.path == newPartialPath {
|
||||
t.Fatalf("staging file should be ignored: %#v", files)
|
||||
}
|
||||
}
|
||||
cancelCh := make(chan struct{})
|
||||
close(cancelCh)
|
||||
if _, err := collectLibraryAudioFiles(dir, cancelCh); err == nil {
|
||||
|
||||
@@ -26,6 +26,12 @@ const (
|
||||
LyricsProviderMusixmatch = "musixmatch"
|
||||
LyricsProviderAppleMusic = "apple_music"
|
||||
LyricsProviderQQMusic = "qqmusic"
|
||||
LyricsProviderSpotify = "spotify"
|
||||
LyricsProviderDeezer = "deezer"
|
||||
LyricsProviderYouTube = "youtube"
|
||||
LyricsProviderKugou = "kugou"
|
||||
LyricsProviderGenius = "genius"
|
||||
LyricsProviderLyricsPlus = "lyricsplus"
|
||||
)
|
||||
|
||||
var DefaultLyricsProviders = []string{
|
||||
@@ -68,6 +74,7 @@ type LyricsFetchOptions struct {
|
||||
IncludeTranslationNetease bool `json:"include_translation_netease"`
|
||||
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
|
||||
MultiPersonWordByWord bool `json:"multi_person_word_by_word"`
|
||||
AppleElrcWordSync bool `json:"apple_elrc_word_sync"`
|
||||
MusixmatchLanguage string `json:"musixmatch_language,omitempty"`
|
||||
}
|
||||
|
||||
@@ -75,6 +82,7 @@ var defaultLyricsFetchOptions = LyricsFetchOptions{
|
||||
IncludeTranslationNetease: false,
|
||||
IncludeRomanizationNetease: false,
|
||||
MultiPersonWordByWord: true,
|
||||
AppleElrcWordSync: false,
|
||||
MusixmatchLanguage: "",
|
||||
}
|
||||
|
||||
@@ -100,6 +108,12 @@ func SetLyricsProviderOrder(providers []string) {
|
||||
LyricsProviderMusixmatch: true,
|
||||
LyricsProviderAppleMusic: true,
|
||||
LyricsProviderQQMusic: true,
|
||||
LyricsProviderSpotify: true,
|
||||
LyricsProviderDeezer: true,
|
||||
LyricsProviderYouTube: true,
|
||||
LyricsProviderKugou: true,
|
||||
LyricsProviderGenius: true,
|
||||
LyricsProviderLyricsPlus: true,
|
||||
}
|
||||
|
||||
var valid []string
|
||||
@@ -130,10 +144,16 @@ func GetLyricsProviderOrder() []string {
|
||||
func GetAvailableLyricsProviders() []map[string]interface{} {
|
||||
return []map[string]interface{}{
|
||||
{"id": LyricsProviderLRCLIB, "name": "LRCLIB", "has_proxy_dependency": false, "description": "Open-source synced lyrics database"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics via Paxsenix"},
|
||||
{"id": LyricsProviderNetease, "name": "Netease", "has_proxy_dependency": true, "description": "NetEase Cloud Music lyrics"},
|
||||
{"id": LyricsProviderMusixmatch, "name": "Musixmatch", "has_proxy_dependency": true, "description": "Musixmatch lyrics"},
|
||||
{"id": LyricsProviderAppleMusic, "name": "Apple Music", "has_proxy_dependency": true, "description": "Apple Music synced lyrics"},
|
||||
{"id": LyricsProviderQQMusic, "name": "QQ Music", "has_proxy_dependency": true, "description": "QQ Music lyrics"},
|
||||
{"id": LyricsProviderSpotify, "name": "Spotify", "has_proxy_dependency": true, "description": "Spotify synced lyrics"},
|
||||
{"id": LyricsProviderDeezer, "name": "Deezer", "has_proxy_dependency": true, "description": "Deezer lyrics"},
|
||||
{"id": LyricsProviderYouTube, "name": "YouTube", "has_proxy_dependency": true, "description": "YouTube lyrics"},
|
||||
{"id": LyricsProviderKugou, "name": "Kugou", "has_proxy_dependency": true, "description": "Kugou lyrics"},
|
||||
{"id": LyricsProviderGenius, "name": "Genius", "has_proxy_dependency": true, "description": "Genius lyrics"},
|
||||
{"id": LyricsProviderLyricsPlus, "name": "LyricsPlus", "has_proxy_dependency": true, "description": "Word-by-word karaoke lyrics (Apple/Musixmatch/Spotify/QQ)"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,12 +171,18 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
|
||||
|
||||
lyricsFetchOptionsMu.Lock()
|
||||
defer lyricsFetchOptionsMu.Unlock()
|
||||
changed := lyricsFetchOptions != normalized
|
||||
lyricsFetchOptions = normalized
|
||||
|
||||
GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v musixmatch_lang=%q\n",
|
||||
if changed {
|
||||
globalLyricsCache.ClearAll()
|
||||
}
|
||||
|
||||
GoLog("[Lyrics] Fetch options set: translation=%v romanization=%v multi_person=%v apple_elrc=%v musixmatch_lang=%q\n",
|
||||
normalized.IncludeTranslationNetease,
|
||||
normalized.IncludeRomanizationNetease,
|
||||
normalized.MultiPersonWordByWord,
|
||||
normalized.AppleElrcWordSync,
|
||||
normalized.MusixmatchLanguage,
|
||||
)
|
||||
}
|
||||
@@ -530,9 +556,9 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
|
||||
case LyricsProviderAppleMusic:
|
||||
appleClient := NewAppleMusicClient()
|
||||
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync)
|
||||
}
|
||||
|
||||
case LyricsProviderQQMusic:
|
||||
@@ -542,6 +568,84 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
lyrics, err = qqClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord)
|
||||
}
|
||||
|
||||
case LyricsProviderSpotify:
|
||||
spotifyClient := NewSpotifyLyricsClient()
|
||||
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
|
||||
}
|
||||
if err != nil && simplifiedTrack != trackName {
|
||||
lyrics, err = spotifyClient.FetchLyrics("", simplifiedTrack, primaryArtist, durationSec)
|
||||
}
|
||||
|
||||
case LyricsProviderDeezer:
|
||||
deezerClient := NewDeezerLyricsClient()
|
||||
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, artistName, durationSec)
|
||||
}
|
||||
|
||||
case LyricsProviderYouTube:
|
||||
youtubeClient := NewYouTubeLyricsClient()
|
||||
lyrics, err = youtubeClient.FetchLyrics(trackName, primaryArtist, durationSec)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = youtubeClient.FetchLyrics(trackName, artistName, durationSec)
|
||||
}
|
||||
if err != nil && simplifiedTrack != trackName {
|
||||
lyrics, err = youtubeClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
|
||||
}
|
||||
|
||||
case LyricsProviderKugou:
|
||||
kugouClient := NewKugouLyricsClient()
|
||||
lyrics, err = kugouClient.FetchLyrics(trackName, primaryArtist, durationSec)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = kugouClient.FetchLyrics(trackName, artistName, durationSec)
|
||||
}
|
||||
if err != nil && simplifiedTrack != trackName {
|
||||
lyrics, err = kugouClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
|
||||
}
|
||||
|
||||
case LyricsProviderGenius:
|
||||
geniusClient := NewGeniusLyricsClient()
|
||||
lyrics, err = geniusClient.FetchLyrics(trackName, primaryArtist, durationSec)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = geniusClient.FetchLyrics(trackName, artistName, durationSec)
|
||||
}
|
||||
if err != nil && simplifiedTrack != trackName {
|
||||
lyrics, err = geniusClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec)
|
||||
}
|
||||
|
||||
case LyricsProviderLyricsPlus:
|
||||
lyricsPlusClient := NewLyricsPlusClient()
|
||||
lyrics, err = lyricsPlusClient.FetchLyrics(
|
||||
trackName,
|
||||
primaryArtist,
|
||||
"",
|
||||
durationSec,
|
||||
fetchOptions.MultiPersonWordByWord,
|
||||
fetchOptions.AppleElrcWordSync,
|
||||
)
|
||||
if err != nil && primaryArtist != artistName {
|
||||
lyrics, err = lyricsPlusClient.FetchLyrics(
|
||||
trackName,
|
||||
artistName,
|
||||
"",
|
||||
durationSec,
|
||||
fetchOptions.MultiPersonWordByWord,
|
||||
fetchOptions.AppleElrcWordSync,
|
||||
)
|
||||
}
|
||||
if err != nil && simplifiedTrack != trackName {
|
||||
lyrics, err = lyricsPlusClient.FetchLyrics(
|
||||
simplifiedTrack,
|
||||
primaryArtist,
|
||||
"",
|
||||
durationSec,
|
||||
fetchOptions.MultiPersonWordByWord,
|
||||
fetchOptions.AppleElrcWordSync,
|
||||
)
|
||||
}
|
||||
|
||||
default:
|
||||
GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName)
|
||||
continue
|
||||
@@ -773,6 +877,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 ""
|
||||
@@ -782,7 +921,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" {
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -15,6 +17,8 @@ type AppleMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
const appleMusicCatalogBaseURL = "https://amp-api.music.apple.com/v1/catalog/us"
|
||||
|
||||
type appleMusicSearchResult struct {
|
||||
ID string `json:"id"`
|
||||
SongName string `json:"songName"`
|
||||
@@ -23,9 +27,33 @@ type appleMusicSearchResult struct {
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
type appleMusicCatalogSearchResponse struct {
|
||||
Results struct {
|
||||
Songs *struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
} `json:"songs"`
|
||||
} `json:"results"`
|
||||
Resources *struct {
|
||||
Songs map[string]struct {
|
||||
Attributes struct {
|
||||
Name string `json:"name"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
DurationInMillis int `json:"durationInMillis"`
|
||||
} `json:"attributes"`
|
||||
} `json:"songs"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
|
||||
type paxResponse struct {
|
||||
Type string `json:"type"` // "Syllable" or "Line"
|
||||
Content []paxLyrics `json:"content"` // List of lyric lines
|
||||
Type string `json:"type"` // "Syllable" or "Line"
|
||||
Content []paxLyrics `json:"content"`
|
||||
ELRC string `json:"elrc"`
|
||||
ELRCMultiPerson string `json:"elrcMultiPerson"`
|
||||
Plain string `json:"plain"`
|
||||
TTMLContent string `json:"ttmlContent"`
|
||||
}
|
||||
|
||||
type paxLyrics struct {
|
||||
@@ -44,6 +72,11 @@ type paxLyricDetail struct {
|
||||
EndTime *int `json:"endtime"`
|
||||
}
|
||||
|
||||
var (
|
||||
appleMusicTokenMu sync.Mutex
|
||||
appleMusicCachedToken string
|
||||
)
|
||||
|
||||
func NewAppleMusicClient() *AppleMusicClient {
|
||||
return &AppleMusicClient{
|
||||
httpClient: NewMetadataHTTPClient(20 * time.Second),
|
||||
@@ -100,36 +133,164 @@ func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackNam
|
||||
return &results[bestIndex]
|
||||
}
|
||||
|
||||
func (c *AppleMusicClient) getAppleMusicToken() (string, error) {
|
||||
appleMusicTokenMu.Lock()
|
||||
defer appleMusicTokenMu.Unlock()
|
||||
|
||||
if appleMusicCachedToken != "" {
|
||||
return appleMusicCachedToken, nil
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create apple music page request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch apple music page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("apple music page returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read apple music page: %w", err)
|
||||
}
|
||||
|
||||
indexPath := regexp.MustCompile(`/assets/index~[^"' <]+\.js`).FindString(string(body))
|
||||
if indexPath == "" {
|
||||
return "", fmt.Errorf("apple music index script not found")
|
||||
}
|
||||
|
||||
jsReq, err := http.NewRequest("GET", "https://beta.music.apple.com"+indexPath, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create apple music script request: %w", err)
|
||||
}
|
||||
jsReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
jsResp, err := c.httpClient.Do(jsReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch apple music script: %w", err)
|
||||
}
|
||||
defer jsResp.Body.Close()
|
||||
|
||||
if jsResp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("apple music script returned HTTP %d", jsResp.StatusCode)
|
||||
}
|
||||
|
||||
jsBody, err := io.ReadAll(jsResp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read apple music script: %w", err)
|
||||
}
|
||||
|
||||
token := regexp.MustCompile(`eyJh[^"' <]+`).FindString(string(jsBody))
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("apple music token not found")
|
||||
}
|
||||
|
||||
appleMusicCachedToken = token
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func clearAppleMusicToken() {
|
||||
appleMusicTokenMu.Lock()
|
||||
defer appleMusicTokenMu.Unlock()
|
||||
appleMusicCachedToken = ""
|
||||
}
|
||||
|
||||
func (c *AppleMusicClient) searchSongWithToken(token, query string) ([]appleMusicSearchResult, error) {
|
||||
params := url.Values{}
|
||||
params.Set("term", query)
|
||||
params.Set("types", "songs")
|
||||
params.Set("limit", "25")
|
||||
params.Set("l", "en-US")
|
||||
params.Set("platform", "web")
|
||||
params.Set("format[resources]", "map")
|
||||
params.Set("include[songs]", "artists")
|
||||
params.Set("extend", "artistUrl")
|
||||
|
||||
searchURL := appleMusicCatalogBaseURL + "/search?" + params.Encode()
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create apple music catalog request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Origin", "https://music.apple.com")
|
||||
req.Header.Set("Referer", "https://music.apple.com/")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
|
||||
req.Header.Set("x-apple-renewal", "true")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apple music catalog search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return nil, fmt.Errorf("apple music catalog search unauthorized")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("apple music catalog search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp appleMusicCatalogSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode apple music catalog response: %w", err)
|
||||
}
|
||||
|
||||
if searchResp.Results.Songs == nil || searchResp.Resources == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
results := make([]appleMusicSearchResult, 0, len(searchResp.Results.Songs.Data))
|
||||
for _, item := range searchResp.Results.Songs.Data {
|
||||
detail, ok := searchResp.Resources.Songs[item.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
attr := detail.Attributes
|
||||
results = append(results, appleMusicSearchResult{
|
||||
ID: item.ID,
|
||||
SongName: attr.Name,
|
||||
ArtistName: attr.ArtistName,
|
||||
AlbumName: attr.AlbumName,
|
||||
Duration: attr.DurationInMillis,
|
||||
})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
encodedQuery := url.QueryEscape(query)
|
||||
searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
token, err := c.getAppleMusicToken()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
searchResp, err := c.searchSongWithToken(token, strings.TrimSpace(query))
|
||||
if err != nil && strings.Contains(strings.ToLower(err.Error()), "unauthorized") {
|
||||
clearAppleMusicToken()
|
||||
token, tokenErr := c.getAppleMusicToken()
|
||||
if tokenErr != nil {
|
||||
return "", tokenErr
|
||||
}
|
||||
searchResp, err = c.searchSongWithToken(token, strings.TrimSpace(query))
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apple music search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp []appleMusicSearchResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode apple music response: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
|
||||
@@ -173,25 +334,50 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
||||
return bodyStr, nil
|
||||
}
|
||||
|
||||
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) {
|
||||
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool, preserveWordTiming bool) (string, error) {
|
||||
var stringPayload string
|
||||
if err := json.Unmarshal([]byte(rawJSON), &stringPayload); err == nil {
|
||||
stringPayload = strings.TrimSpace(stringPayload)
|
||||
if stringPayload != "" {
|
||||
return stringPayload, nil
|
||||
}
|
||||
}
|
||||
|
||||
var paxResp paxResponse
|
||||
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
|
||||
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil
|
||||
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil &&
|
||||
(paxResp.Content != nil ||
|
||||
strings.TrimSpace(paxResp.ELRCMultiPerson) != "" ||
|
||||
strings.TrimSpace(paxResp.ELRC) != "" ||
|
||||
strings.TrimSpace(paxResp.Plain) != "" ||
|
||||
strings.TrimSpace(paxResp.TTMLContent) != "") {
|
||||
if preserveWordTiming && multiPersonWordByWord && strings.TrimSpace(paxResp.ELRCMultiPerson) != "" {
|
||||
return strings.TrimSpace(paxResp.ELRCMultiPerson), nil
|
||||
}
|
||||
if preserveWordTiming && strings.TrimSpace(paxResp.ELRC) != "" {
|
||||
return strings.TrimSpace(paxResp.ELRC), nil
|
||||
}
|
||||
if strings.TrimSpace(paxResp.Plain) != "" && len(paxResp.Content) == 0 {
|
||||
return strings.TrimSpace(paxResp.Plain), nil
|
||||
}
|
||||
if len(paxResp.Content) == 0 {
|
||||
return "", fmt.Errorf("unsupported apple music lyrics payload")
|
||||
}
|
||||
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord, preserveWordTiming), nil
|
||||
}
|
||||
|
||||
var directLyrics []paxLyrics
|
||||
if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 {
|
||||
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil
|
||||
return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord, preserveWordTiming), nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to parse pax lyrics response")
|
||||
}
|
||||
|
||||
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) {
|
||||
func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail, preserveWordTiming bool) {
|
||||
lastStart := ""
|
||||
|
||||
for _, syllable := range details {
|
||||
if syllable.Timestamp != nil {
|
||||
if preserveWordTiming && syllable.Timestamp != nil {
|
||||
start := fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.Timestamp)))
|
||||
if start != lastStart {
|
||||
builder.WriteString(start)
|
||||
@@ -204,13 +390,13 @@ func appendPaxLyricDetail(builder *strings.Builder, details []paxLyricDetail) {
|
||||
builder.WriteString(" ")
|
||||
}
|
||||
|
||||
if syllable.EndTime != nil {
|
||||
if preserveWordTiming && syllable.EndTime != nil {
|
||||
builder.WriteString(fmt.Sprintf("<%s>", msToLRCTimestampInline(int64(*syllable.EndTime))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool) string {
|
||||
func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByWord bool, preserveWordTiming bool) string {
|
||||
var sb strings.Builder
|
||||
|
||||
for i, line := range content {
|
||||
@@ -230,11 +416,11 @@ func formatPaxContent(lyricsType string, content []paxLyrics, multiPersonWordByW
|
||||
}
|
||||
}
|
||||
|
||||
appendPaxLyricDetail(&sb, line.Text)
|
||||
appendPaxLyricDetail(&sb, line.Text, preserveWordTiming)
|
||||
|
||||
if line.Background && multiPersonWordByWord && len(line.BackgroundText) > 0 {
|
||||
sb.WriteString("\n[bg:")
|
||||
appendPaxLyricDetail(&sb, line.BackgroundText)
|
||||
appendPaxLyricDetail(&sb, line.BackgroundText, preserveWordTiming)
|
||||
sb.WriteString("]")
|
||||
}
|
||||
} else {
|
||||
@@ -253,6 +439,7 @@ func (c *AppleMusicClient) FetchLyrics(
|
||||
artistName string,
|
||||
durationSec float64,
|
||||
multiPersonWordByWord bool,
|
||||
preserveWordTiming bool,
|
||||
) (*LyricsResponse, error) {
|
||||
songID, err := c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
@@ -267,8 +454,12 @@ func (c *AppleMusicClient) FetchLyrics(
|
||||
return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg)
|
||||
}
|
||||
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord)
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, preserveWordTiming)
|
||||
if err != nil {
|
||||
trimmedRaw := strings.TrimSpace(rawLyrics)
|
||||
if strings.HasPrefix(trimmedRaw, "{") || strings.HasPrefix(trimmedRaw, "[") {
|
||||
return nil, err
|
||||
}
|
||||
lrcText = rawLyrics
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
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.my.id",
|
||||
"https://lyricsplus.atomix.one",
|
||||
"https://lyricsplus.binimum.org",
|
||||
"https://lyricsplus.prjktla.workers.dev",
|
||||
"https://lyricsplus-seven.vercel.app",
|
||||
"https://lyrics-plus-backend.vercel.app",
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SpotifyLyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type DeezerLyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type YouTubeLyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type KugouLyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type GeniusLyricsClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type spotifyLyricsSearchResult struct {
|
||||
TrackID string `json:"trackId"`
|
||||
Name string `json:"name"`
|
||||
ArtistName string `json:"artistName"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
type youtubeLyricsSearchResult struct {
|
||||
VideoID string `json:"videoId"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
type kugouLyricsSearchResult struct {
|
||||
Hash string `json:"hash"`
|
||||
Title string `json:"title"`
|
||||
Artist string `json:"artist"`
|
||||
Duration float64 `json:"duration"`
|
||||
}
|
||||
|
||||
type geniusSearchResponse struct {
|
||||
Response struct {
|
||||
Sections []struct {
|
||||
Hits []struct {
|
||||
Type string `json:"type"`
|
||||
Result struct {
|
||||
Title string `json:"title"`
|
||||
ArtistNames string `json:"artist_names"`
|
||||
PrimaryArtistNames string `json:"primary_artist_names"`
|
||||
URL string `json:"url"`
|
||||
} `json:"result"`
|
||||
} `json:"hits"`
|
||||
} `json:"sections"`
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
type paxsenixLyricsObject struct {
|
||||
Type string `json:"type"`
|
||||
Content []paxLyrics `json:"content"`
|
||||
Lyrics []paxLyrics `json:"lyrics"`
|
||||
LyricsText string `json:"lyrics_text"`
|
||||
PlainLyrics string `json:"plain_lyrics"`
|
||||
}
|
||||
|
||||
func NewSpotifyLyricsClient() *SpotifyLyricsClient {
|
||||
return &SpotifyLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
|
||||
}
|
||||
|
||||
func NewDeezerLyricsClient() *DeezerLyricsClient {
|
||||
return &DeezerLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
|
||||
}
|
||||
|
||||
func NewYouTubeLyricsClient() *YouTubeLyricsClient {
|
||||
return &YouTubeLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
|
||||
}
|
||||
|
||||
func NewKugouLyricsClient() *KugouLyricsClient {
|
||||
return &KugouLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
|
||||
}
|
||||
|
||||
func NewGeniusLyricsClient() *GeniusLyricsClient {
|
||||
return &GeniusLyricsClient{httpClient: NewMetadataHTTPClient(15 * time.Second)}
|
||||
}
|
||||
|
||||
func fetchPaxsenixBody(httpClient *http.Client, endpoint string, params url.Values) (string, error) {
|
||||
fullURL := endpoint
|
||||
if len(params) > 0 {
|
||||
fullURL += "?" + params.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, errMsg)
|
||||
}
|
||||
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload {
|
||||
return "", fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
if trimmed == "" {
|
||||
return "", fmt.Errorf("empty response")
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
func parsePaxsenixLyricsPayload(raw, provider string, multiPersonWordByWord bool) (*LyricsResponse, error) {
|
||||
var lrcPayload string
|
||||
if err := json.Unmarshal([]byte(raw), &lrcPayload); err == nil {
|
||||
lrcPayload = strings.TrimSpace(lrcPayload)
|
||||
if lrcPayload == "" {
|
||||
return nil, fmt.Errorf("%s returned empty lyrics", provider)
|
||||
}
|
||||
return lyricsResponseFromText(lrcPayload, provider), nil
|
||||
}
|
||||
|
||||
var rawObject map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &rawObject); err == nil {
|
||||
for _, key := range []string{"lyrics", "lyric", "lyrics_text", "plain_lyrics"} {
|
||||
var value string
|
||||
if rawValue, ok := rawObject[key]; ok && json.Unmarshal(rawValue, &value) == nil {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return lyricsResponseFromText(value, provider), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var payload paxsenixLyricsObject
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err == nil {
|
||||
switch {
|
||||
case strings.TrimSpace(payload.LyricsText) != "":
|
||||
return lyricsResponseFromText(payload.LyricsText, provider), nil
|
||||
case len(payload.Lyrics) > 0:
|
||||
return lyricsResponseFromText(formatPaxContent("Syllable", payload.Lyrics, multiPersonWordByWord, true), provider), nil
|
||||
case len(payload.Content) > 0:
|
||||
lyricsType := payload.Type
|
||||
if lyricsType == "" {
|
||||
lyricsType = "Syllable"
|
||||
}
|
||||
return lyricsResponseFromText(formatPaxContent(lyricsType, payload.Content, multiPersonWordByWord, true), provider), nil
|
||||
case strings.TrimSpace(payload.PlainLyrics) != "":
|
||||
return lyricsResponseFromText(payload.PlainLyrics, provider), nil
|
||||
}
|
||||
}
|
||||
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, "{") && !strings.HasPrefix(trimmed, "[") {
|
||||
return lyricsResponseFromText(trimmed, provider), nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to decode %s lyrics response", provider)
|
||||
}
|
||||
|
||||
func lyricsResponseFromText(text, provider string) *LyricsResponse {
|
||||
lines := parseSyncedLyrics(text)
|
||||
if len(lines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: lines,
|
||||
SyncType: "LINE_SYNCED",
|
||||
PlainLyrics: plainLyricsFromTimedLines(lines),
|
||||
Provider: provider,
|
||||
Source: provider,
|
||||
}
|
||||
}
|
||||
|
||||
plainLines := plainTextLyricsLines(text)
|
||||
if len(plainLines) > 0 {
|
||||
return &LyricsResponse{
|
||||
Lines: plainLines,
|
||||
SyncType: "UNSYNCED",
|
||||
PlainLyrics: text,
|
||||
Provider: provider,
|
||||
Source: provider,
|
||||
}
|
||||
}
|
||||
|
||||
return &LyricsResponse{Provider: provider, Source: provider}
|
||||
}
|
||||
|
||||
func normalizeSpotifyLyricsID(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || strings.HasPrefix(strings.ToLower(raw), "deezer:") {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(raw), "spotify:") {
|
||||
parts := strings.Split(raw, ":")
|
||||
raw = parts[len(parts)-1]
|
||||
}
|
||||
if strings.Contains(raw, "spotify.com/track/") {
|
||||
raw = extractSpotifyIDFromURL(raw)
|
||||
}
|
||||
raw = strings.TrimSpace(strings.Split(raw, "?")[0])
|
||||
if regexpSpotifyTrackID.MatchString(raw) {
|
||||
return raw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var regexpSpotifyTrackID = regexp.MustCompile(`^[A-Za-z0-9]{22}$`)
|
||||
|
||||
func (c *SpotifyLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := strings.TrimSpace(trackName + " " + artistName)
|
||||
if query == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/spotify/search", params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("spotify search failed: %w", err)
|
||||
}
|
||||
|
||||
var results []spotifyLyricsSearchResult
|
||||
if err := json.Unmarshal([]byte(raw), &results); err != nil {
|
||||
return "", fmt.Errorf("failed to decode spotify search: %w", err)
|
||||
}
|
||||
best := selectBestSpotifyLyricsSearchResult(results, trackName, artistName, durationSec)
|
||||
if best == nil || strings.TrimSpace(best.TrackID) == "" {
|
||||
return "", fmt.Errorf("no songs found on spotify")
|
||||
}
|
||||
return strings.TrimSpace(best.TrackID), nil
|
||||
}
|
||||
|
||||
func selectBestSpotifyLyricsSearchResult(results []spotifyLyricsSearchResult, trackName, artistName string, durationSec float64) *spotifyLyricsSearchResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bestIndex := 0
|
||||
bestScore := -1
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
score := scoreLyricsSearchCandidate(result.Name, result.ArtistName, parseClockDuration(result.Duration), trackName, artistName, durationSec)
|
||||
if score > bestScore {
|
||||
bestIndex = i
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
return &results[bestIndex]
|
||||
}
|
||||
|
||||
func (c *SpotifyLyricsClient) FetchLyricsByID(trackID string) (*LyricsResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("id", trackID)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/spotify/lyrics", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("spotify lyrics fetch failed: %w", err)
|
||||
}
|
||||
return parsePaxsenixLyricsPayload(raw, "Spotify", false)
|
||||
}
|
||||
|
||||
func (c *SpotifyLyricsClient) FetchLyrics(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
trackID := normalizeSpotifyLyricsID(spotifyID)
|
||||
if trackID == "" {
|
||||
var err error
|
||||
trackID, err = c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return c.FetchLyricsByID(trackID)
|
||||
}
|
||||
|
||||
func normalizeDeezerLyricsID(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if strings.HasPrefix(strings.ToLower(raw), "deezer:") {
|
||||
raw = strings.TrimSpace(raw[len("deezer:"):])
|
||||
}
|
||||
if strings.Contains(raw, "deezer.com/") {
|
||||
raw = extractDeezerIDFromURL(raw)
|
||||
}
|
||||
raw = strings.TrimSpace(strings.Split(raw, "?")[0])
|
||||
if _, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
return raw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *DeezerLyricsClient) FetchLyricsByID(trackID string, multiPersonWordByWord bool) (*LyricsResponse, error) {
|
||||
params := url.Values{}
|
||||
params.Set("id", trackID)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/deezer/lyrics", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("deezer lyrics fetch failed: %w", err)
|
||||
}
|
||||
return parsePaxsenixLyricsPayload(raw, "Deezer", multiPersonWordByWord)
|
||||
}
|
||||
|
||||
func (c *DeezerLyricsClient) FetchLyrics(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
deezerID := normalizeDeezerLyricsID(spotifyID)
|
||||
if deezerID == "" {
|
||||
spotifyTrackID := normalizeSpotifyLyricsID(spotifyID)
|
||||
if spotifyTrackID == "" {
|
||||
return nil, fmt.Errorf("deezer provider needs a deezer id or spotify id")
|
||||
}
|
||||
resolvedID, err := NewSongLinkClient().GetDeezerIDFromSpotify(spotifyTrackID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve deezer id: %w", err)
|
||||
}
|
||||
deezerID = normalizeDeezerLyricsID(resolvedID)
|
||||
}
|
||||
if deezerID == "" {
|
||||
return nil, fmt.Errorf("deezer id unavailable")
|
||||
}
|
||||
return c.FetchLyricsByID(deezerID, true)
|
||||
}
|
||||
|
||||
func (c *YouTubeLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := strings.TrimSpace(trackName + " " + artistName)
|
||||
if query == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/youtube/search", params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("youtube search failed: %w", err)
|
||||
}
|
||||
|
||||
var results []youtubeLyricsSearchResult
|
||||
if err := json.Unmarshal([]byte(raw), &results); err != nil {
|
||||
return "", fmt.Errorf("failed to decode youtube search: %w", err)
|
||||
}
|
||||
best := selectBestYouTubeLyricsSearchResult(results, trackName, artistName, durationSec)
|
||||
if best == nil || strings.TrimSpace(best.VideoID) == "" {
|
||||
return "", fmt.Errorf("no songs found on youtube")
|
||||
}
|
||||
return strings.TrimSpace(best.VideoID), nil
|
||||
}
|
||||
|
||||
func selectBestYouTubeLyricsSearchResult(results []youtubeLyricsSearchResult, trackName, artistName string, durationSec float64) *youtubeLyricsSearchResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bestIndex := 0
|
||||
bestScore := -1
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
score := scoreLyricsSearchCandidate(result.Title, result.Author, parseClockDuration(result.Duration), trackName, artistName, durationSec)
|
||||
if score > bestScore {
|
||||
bestIndex = i
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
return &results[bestIndex]
|
||||
}
|
||||
|
||||
func (c *YouTubeLyricsClient) FetchLyrics(trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
videoID, err := c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("id", videoID)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/youtube/lyrics", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("youtube lyrics fetch failed: %w", err)
|
||||
}
|
||||
return parsePaxsenixLyricsPayload(raw, "YouTube", false)
|
||||
}
|
||||
|
||||
func (c *KugouLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := strings.TrimSpace(trackName + " " + artistName)
|
||||
if query == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/kugou/search", params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("kugou search failed: %w", err)
|
||||
}
|
||||
|
||||
var results []kugouLyricsSearchResult
|
||||
if err := json.Unmarshal([]byte(raw), &results); err != nil {
|
||||
return "", fmt.Errorf("failed to decode kugou search: %w", err)
|
||||
}
|
||||
best := selectBestKugouLyricsSearchResult(results, trackName, artistName, durationSec)
|
||||
if best == nil || strings.TrimSpace(best.Hash) == "" {
|
||||
return "", fmt.Errorf("no songs found on kugou")
|
||||
}
|
||||
return strings.TrimSpace(best.Hash), nil
|
||||
}
|
||||
|
||||
func selectBestKugouLyricsSearchResult(results []kugouLyricsSearchResult, trackName, artistName string, durationSec float64) *kugouLyricsSearchResult {
|
||||
if len(results) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bestIndex := 0
|
||||
bestScore := -1
|
||||
for i := range results {
|
||||
result := &results[i]
|
||||
score := scoreLyricsSearchCandidate(result.Title, result.Artist, result.Duration, trackName, artistName, durationSec)
|
||||
if score > bestScore {
|
||||
bestIndex = i
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
return &results[bestIndex]
|
||||
}
|
||||
|
||||
func (c *KugouLyricsClient) FetchLyrics(trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
hash, err := c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("id", hash)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/kugou/lyrics", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kugou lyrics fetch failed: %w", err)
|
||||
}
|
||||
return parsePaxsenixLyricsPayload(raw, "Kugou", false)
|
||||
}
|
||||
|
||||
func (c *GeniusLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := strings.TrimSpace(trackName + " " + artistName)
|
||||
if query == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
params.Set("per_page", "10")
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://genius.com/api/search/multi", params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("genius search failed: %w", err)
|
||||
}
|
||||
|
||||
var results geniusSearchResponse
|
||||
if err := json.Unmarshal([]byte(raw), &results); err != nil {
|
||||
return "", fmt.Errorf("failed to decode genius search: %w", err)
|
||||
}
|
||||
|
||||
bestURL := ""
|
||||
bestScore := -1
|
||||
for _, section := range results.Response.Sections {
|
||||
for _, hit := range section.Hits {
|
||||
if hit.Type != "song" || strings.TrimSpace(hit.Result.URL) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
artist := hit.Result.PrimaryArtistNames
|
||||
if strings.TrimSpace(artist) == "" {
|
||||
artist = hit.Result.ArtistNames
|
||||
}
|
||||
score := scoreLyricsSearchCandidate(hit.Result.Title, artist, 0, trackName, artistName, durationSec)
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestURL = strings.TrimSpace(hit.Result.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestURL == "" {
|
||||
return "", fmt.Errorf("no songs found on genius")
|
||||
}
|
||||
return bestURL, nil
|
||||
}
|
||||
|
||||
func (c *GeniusLyricsClient) FetchLyrics(trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||
geniusURL, err := c.SearchSong(trackName, artistName, durationSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("url", geniusURL)
|
||||
raw, err := fetchPaxsenixBody(c.httpClient, "https://lyrics.paxsenix.org/genius/lyrics", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("genius lyrics fetch failed: %w", err)
|
||||
}
|
||||
return parsePaxsenixLyricsPayload(raw, "Genius", false)
|
||||
}
|
||||
|
||||
func scoreLyricsSearchCandidate(candidateTrack, candidateArtist string, candidateDuration float64, trackName, artistName string, durationSec float64) int {
|
||||
normalizedTrack := strings.ToLower(strings.TrimSpace(simplifyTrackName(trackName)))
|
||||
normalizedArtist := strings.ToLower(strings.TrimSpace(normalizeArtistName(artistName)))
|
||||
candidateTrack = strings.ToLower(strings.TrimSpace(simplifyTrackName(candidateTrack)))
|
||||
candidateArtist = strings.ToLower(strings.TrimSpace(normalizeArtistName(candidateArtist)))
|
||||
|
||||
score := 0
|
||||
switch {
|
||||
case candidateTrack == normalizedTrack:
|
||||
score += 50
|
||||
case strings.Contains(candidateTrack, normalizedTrack) || strings.Contains(normalizedTrack, candidateTrack):
|
||||
score += 25
|
||||
}
|
||||
|
||||
switch {
|
||||
case candidateArtist == normalizedArtist:
|
||||
score += 60
|
||||
case strings.Contains(candidateArtist, normalizedArtist) || strings.Contains(normalizedArtist, candidateArtist):
|
||||
score += 30
|
||||
}
|
||||
|
||||
if durationSec > 0 && candidateDuration > 0 {
|
||||
diff := math.Abs(candidateDuration - durationSec)
|
||||
if diff <= durationToleranceSec {
|
||||
score += 20
|
||||
}
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
func parseClockDuration(value string) float64 {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
parts := strings.Split(value, ":")
|
||||
total := 0
|
||||
for _, part := range parts {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(part))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
total = total*60 + n
|
||||
}
|
||||
return float64(total)
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func formatQQLyricsMetadataToLRC(rawJSON string, multiPersonWordByWord bool) (st
|
||||
if len(response.Lyrics) == 0 {
|
||||
return "", fmt.Errorf("qq metadata lyrics response was empty")
|
||||
}
|
||||
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord), nil
|
||||
return formatPaxContent("Syllable", response.Lyrics, multiPersonWordByWord, true), nil
|
||||
}
|
||||
|
||||
func (c *QQMusicClient) FetchLyrics(
|
||||
@@ -106,7 +106,7 @@ func (c *QQMusicClient) FetchLyrics(
|
||||
|
||||
lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord)
|
||||
if err != nil {
|
||||
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord); fallbackErr == nil {
|
||||
if fallback, fallbackErr := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, true); fallbackErr == nil {
|
||||
lrcText = fallback
|
||||
} else {
|
||||
lrcText = rawLyrics
|
||||
|
||||
@@ -131,14 +131,18 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
||||
clearAppleMusicToken()
|
||||
defer clearAppleMusicToken()
|
||||
|
||||
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) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/apple-music/search"):
|
||||
if req.URL.Query().Get("q") == "bad" {
|
||||
return &http.Response{StatusCode: 500, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`error`)), Request: req}, nil
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"id":"apple-2","songName":"Other","artistName":"Other","duration":1000},{"id":"apple-1","songName":"Song","artistName":"Artist","albumName":"Album","duration":180000}]`)), Request: req}, nil
|
||||
case req.URL.Host == "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
|
||||
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"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(paxJSON)), Request: req}, nil
|
||||
default:
|
||||
@@ -156,13 +160,30 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
||||
if err != nil || !strings.Contains(rawApple, "Syllable") {
|
||||
t.Fatalf("apple raw = %q/%v", rawApple, err)
|
||||
}
|
||||
appleLyrics, err := apple.FetchLyrics("Song", "Artist", 180, true)
|
||||
appleLyrics, err := apple.FetchLyrics("Song", "Artist", 180, true, true)
|
||||
if err != nil || appleLyrics.SyncType != "LINE_SYNCED" || appleLyrics.Provider != "Apple Music" {
|
||||
t.Fatalf("apple lyrics = %#v/%v", appleLyrics, err)
|
||||
}
|
||||
if plain, err := formatPaxLyricsToLRC(`[{"timestamp":2000,"text":[{"text":"Plain","part":false}]}]`, false); err != nil || !strings.Contains(plain, "Plain") {
|
||||
if plain, err := formatPaxLyricsToLRC(`[{"timestamp":2000,"text":[{"text":"Plain","part":false}]}]`, false, false); err != nil || !strings.Contains(plain, "Plain") {
|
||||
t.Fatalf("direct pax = %q/%v", plain, err)
|
||||
}
|
||||
lineOnly, err := formatPaxLyricsToLRC(paxJSON, true, false)
|
||||
if err != nil {
|
||||
t.Fatalf("line-only pax = %v", err)
|
||||
}
|
||||
if strings.Contains(lineOnly, "<00:") {
|
||||
t.Fatalf("line-only pax should not include inline word timing: %q", lineOnly)
|
||||
}
|
||||
elrc, err := formatPaxLyricsToLRC(paxJSON, true, true)
|
||||
if err != nil {
|
||||
t.Fatalf("elrc pax = %v", err)
|
||||
}
|
||||
if !strings.Contains(elrc, "<00:") {
|
||||
t.Fatalf("elrc pax should include inline word timing: %q", elrc)
|
||||
}
|
||||
if preferred, err := formatPaxLyricsToLRC(`{"elrcMultiPerson":"[00:01.00]v1:<00:01.00>Hello","content":[{"timestamp":1000,"text":[{"text":"Fallback","part":false}]}]}`, true, true); err != nil || !strings.Contains(preferred, "Hello") {
|
||||
t.Fatalf("preferred apple elrc = %q/%v", preferred, err)
|
||||
}
|
||||
if _, err := apple.SearchSong("", "", 0); err == nil {
|
||||
t.Fatal("expected empty apple search error")
|
||||
}
|
||||
@@ -233,4 +254,72 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
||||
if _, err := formatQQLyricsMetadataToLRC(`{"lyrics":[]}`, false); err == nil {
|
||||
t.Fatal("expected empty QQ metadata error")
|
||||
}
|
||||
|
||||
spotify := &SpotifyLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/spotify/search"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"trackId":"spotify-1","name":"Song","artistName":"Artist","duration":"03:00"}]`)), Request: req}, nil
|
||||
case strings.Contains(req.URL.Path, "/spotify/lyrics"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`"[00:01.00]Spotify"`)), Request: req}, nil
|
||||
default:
|
||||
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||
}
|
||||
})}}
|
||||
spotifyLyrics, err := spotify.FetchLyrics("", "Song", "Artist", 180)
|
||||
if err != nil || spotifyLyrics.Provider != "Spotify" || spotifyLyrics.SyncType != "LINE_SYNCED" {
|
||||
t.Fatalf("spotify lyrics = %#v/%v", spotifyLyrics, err)
|
||||
}
|
||||
|
||||
deezer := &DeezerLyricsClient{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(`{"lyrics":[{"timestamp":1000,"text":[{"text":"Deezer","part":false}]}]}`)), Request: req}, nil
|
||||
})}}
|
||||
deezerLyrics, err := deezer.FetchLyricsByID("123", false)
|
||||
if err != nil || deezerLyrics.Provider != "Deezer" || deezerLyrics.SyncType != "LINE_SYNCED" {
|
||||
t.Fatalf("deezer lyrics = %#v/%v", deezerLyrics, err)
|
||||
}
|
||||
|
||||
youtube := &YouTubeLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/youtube/search"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"videoId":"yt-1","title":"Song","author":"Artist","duration":"3:00"}]`)), Request: req}, nil
|
||||
case strings.Contains(req.URL.Path, "/youtube/lyrics"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`"[00:01.00]YouTube"`)), Request: req}, nil
|
||||
default:
|
||||
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||
}
|
||||
})}}
|
||||
youtubeLyrics, err := youtube.FetchLyrics("Song", "Artist", 180)
|
||||
if err != nil || youtubeLyrics.Provider != "YouTube" || youtubeLyrics.SyncType != "LINE_SYNCED" {
|
||||
t.Fatalf("youtube lyrics = %#v/%v", youtubeLyrics, err)
|
||||
}
|
||||
|
||||
kugou := &KugouLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/kugou/search"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"hash":"kg-1","title":"Song","artist":"Artist","duration":180}]`)), Request: req}, nil
|
||||
case strings.Contains(req.URL.Path, "/kugou/lyrics"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"lyrics_text":"[00:01.00]Kugou"}`)), Request: req}, nil
|
||||
default:
|
||||
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||
}
|
||||
})}}
|
||||
kugouLyrics, err := kugou.FetchLyrics("Song", "Artist", 180)
|
||||
if err != nil || kugouLyrics.Provider != "Kugou" || kugouLyrics.SyncType != "LINE_SYNCED" {
|
||||
t.Fatalf("kugou lyrics = %#v/%v", kugouLyrics, err)
|
||||
}
|
||||
|
||||
genius := &GeniusLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/api/search/multi"):
|
||||
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
|
||||
default:
|
||||
return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil
|
||||
}
|
||||
})}}
|
||||
geniusLyrics, err := genius.FetchLyrics("Song", "Artist", 180)
|
||||
if err != nil || geniusLyrics.Provider != "Genius" || geniusLyrics.SyncType != "UNSYNCED" {
|
||||
t.Fatalf("genius lyrics = %#v/%v", geniusLyrics, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -872,7 +872,7 @@ func ExtractLyrics(filePath string) (string, error) {
|
||||
return extractLyricsFromSidecarLRC(filePath)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".aac") {
|
||||
if strings.HasSuffix(lower, ".m4a") || strings.HasSuffix(lower, ".mp4") || strings.HasSuffix(lower, ".aac") {
|
||||
lyrics, err := extractLyricsFromM4A(filePath)
|
||||
if err == nil && strings.TrimSpace(lyrics) != "" {
|
||||
return lyrics, nil
|
||||
@@ -906,6 +906,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)
|
||||
}
|
||||
|
||||
@@ -1578,10 +1604,12 @@ func looksLikeEmbeddedLyrics(value string) bool {
|
||||
}
|
||||
|
||||
type AudioQuality struct {
|
||||
BitDepth int `json:"bit_depth"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
TotalSamples int64 `json:"total_samples"`
|
||||
Duration int `json:"duration"`
|
||||
BitDepth int `json:"bit_depth"`
|
||||
SampleRate int `json:"sample_rate"`
|
||||
TotalSamples int64 `json:"total_samples"`
|
||||
Duration int `json:"duration"`
|
||||
Bitrate int `json:"bitrate,omitempty"` // kbps, estimated for compressed MP4-family streams
|
||||
Codec string `json:"codec,omitempty"`
|
||||
}
|
||||
|
||||
func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
@@ -1632,6 +1660,7 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
||||
SampleRate: sampleRate,
|
||||
TotalSamples: totalSamples,
|
||||
Duration: duration,
|
||||
Codec: "flac",
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1695,9 +1724,11 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||
// [26:28] reserved
|
||||
// [28:32] samplerate (16.16 fixed-point)
|
||||
sampleRate := int(buf[28])<<8 | int(buf[29])
|
||||
bitDepth := int(buf[22])<<8 | int(buf[23])
|
||||
bitDepth := 0
|
||||
codec := normalizeM4AAudioCodec(atomType)
|
||||
|
||||
if atomType == "alac" {
|
||||
bitDepth = int(buf[22])<<8 | int(buf[23])
|
||||
if alacBitDepth, alacSampleRate, ok := readALACSpecificConfig(f, sampleOffset, fileSize); ok {
|
||||
if alacBitDepth > 0 {
|
||||
bitDepth = alacBitDepth
|
||||
@@ -1706,24 +1737,75 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||
sampleRate = alacSampleRate
|
||||
}
|
||||
}
|
||||
} else if atomType == "fLaC" {
|
||||
bitDepth = int(buf[22])<<8 | int(buf[23])
|
||||
if flacBitDepth, flacSampleRate, flacTotalSamples, ok := readMP4FLACSpecificConfig(f, sampleOffset, fileSize); ok {
|
||||
if flacBitDepth > 0 {
|
||||
bitDepth = flacBitDepth
|
||||
}
|
||||
if flacSampleRate > 0 {
|
||||
sampleRate = flacSampleRate
|
||||
}
|
||||
if flacTotalSamples > 0 && sampleRate > 0 && duration <= 0 {
|
||||
duration = int(flacTotalSamples / int64(sampleRate))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bitDepth <= 0 {
|
||||
bitDepth = 16
|
||||
bitrate := estimateAudioBitrateKbps(fileSize, duration)
|
||||
if bitrate > 0 && bitrate < 16 {
|
||||
bitrate = 0
|
||||
}
|
||||
return AudioQuality{
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Duration: duration,
|
||||
Bitrate: bitrate,
|
||||
Codec: codec,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate, Duration: duration}, nil
|
||||
func normalizeM4AAudioCodec(atomType string) string {
|
||||
switch atomType {
|
||||
case "mp4a":
|
||||
return "aac"
|
||||
case "alac":
|
||||
return "alac"
|
||||
case "fLaC":
|
||||
return "flac"
|
||||
case "ec-3":
|
||||
return "eac3"
|
||||
case "ac-3":
|
||||
return "ac3"
|
||||
case "ac-4":
|
||||
return "ac4"
|
||||
default:
|
||||
return strings.TrimSpace(atomType)
|
||||
}
|
||||
}
|
||||
|
||||
func estimateAudioBitrateKbps(fileSize int64, durationSeconds int) int {
|
||||
if fileSize <= 0 || durationSeconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int(math.Round(float64(fileSize*8) / float64(durationSeconds) / 1000.0))
|
||||
}
|
||||
|
||||
func readM4ADurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) int {
|
||||
childStart := moovHeader.offset + moovHeader.headerSize
|
||||
childSize := moovHeader.size - moovHeader.headerSize
|
||||
mvhdHeader, found, err := findAtomInRange(f, childStart, childSize, "mvhd", fileSize)
|
||||
if err != nil || !found {
|
||||
return 0
|
||||
if err == nil && found {
|
||||
if duration := readMP4DurationAtomSeconds(f, mvhdHeader, fileSize); duration > 0 {
|
||||
return duration
|
||||
}
|
||||
}
|
||||
|
||||
payloadOffset := mvhdHeader.offset + mvhdHeader.headerSize
|
||||
return readM4ATrackDurationSeconds(f, moovHeader, fileSize)
|
||||
}
|
||||
|
||||
func readMP4DurationAtomSeconds(f *os.File, header atomHeader, fileSize int64) int {
|
||||
payloadOffset := header.offset + header.headerSize
|
||||
versionBuf := make([]byte, 1)
|
||||
if _, err := f.ReadAt(versionBuf, payloadOffset); err != nil {
|
||||
return 0
|
||||
@@ -1754,6 +1836,53 @@ func readM4ADurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) i
|
||||
return int(math.Round(float64(duration) / float64(timescale)))
|
||||
}
|
||||
|
||||
func readM4ATrackDurationSeconds(f *os.File, moovHeader atomHeader, fileSize int64) int {
|
||||
childStart := moovHeader.offset + moovHeader.headerSize
|
||||
childSize := moovHeader.size - moovHeader.headerSize
|
||||
bestDuration := 0
|
||||
_ = walkMP4AtomsInRange(f, childStart, childSize, fileSize, func(header atomHeader) bool {
|
||||
if header.typ == "mdhd" {
|
||||
if duration := readMP4DurationAtomSeconds(f, header, fileSize); duration > bestDuration {
|
||||
bestDuration = duration
|
||||
}
|
||||
return false
|
||||
}
|
||||
return header.typ == "trak" || header.typ == "mdia"
|
||||
})
|
||||
return bestDuration
|
||||
}
|
||||
|
||||
func walkMP4AtomsInRange(f *os.File, start, size, fileSize int64, visit func(atomHeader) bool) error {
|
||||
if size <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
end := start + size
|
||||
for pos := start; pos+8 <= end; {
|
||||
header, err := readAtomHeaderAt(f, pos, fileSize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
atomSize := header.size
|
||||
if atomSize == 0 {
|
||||
atomSize = end - pos
|
||||
}
|
||||
if atomSize < header.headerSize {
|
||||
return fmt.Errorf("invalid atom size for %s", header.typ)
|
||||
}
|
||||
header.size = atomSize
|
||||
if visit(header) {
|
||||
childStart := header.offset + header.headerSize
|
||||
childSize := header.size - header.headerSize
|
||||
if err := walkMP4AtomsInRange(f, childStart, childSize, fileSize, visit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
pos += atomSize
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, bool) {
|
||||
if sampleOffset < 4 {
|
||||
return 0, 0, false
|
||||
@@ -1788,6 +1917,79 @@ func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int,
|
||||
return parseALACSpecificConfig(payload)
|
||||
}
|
||||
|
||||
func readMP4FLACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, int64, bool) {
|
||||
if sampleOffset < 4 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
|
||||
sampleEntryHeader, err := readAtomHeaderAt(f, sampleOffset-4, fileSize)
|
||||
if err != nil {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
|
||||
childStart := sampleOffset + 32
|
||||
childEnd := sampleEntryHeader.offset + sampleEntryHeader.size
|
||||
if childStart >= childEnd {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
|
||||
configHeader, found, err := findAtomInRange(f, childStart, childEnd-childStart, "dfLa", fileSize)
|
||||
if err != nil || !found {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
|
||||
payloadSize := configHeader.size - configHeader.headerSize
|
||||
if payloadSize <= 0 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
|
||||
payload := make([]byte, payloadSize)
|
||||
if _, err := f.ReadAt(payload, configHeader.offset+configHeader.headerSize); err != nil {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
|
||||
return parseMP4FLACSpecificConfig(payload)
|
||||
}
|
||||
|
||||
func parseMP4FLACSpecificConfig(payload []byte) (int, int, int64, bool) {
|
||||
if len(payload) >= 4 && string(payload[:4]) == "fLaC" {
|
||||
payload = payload[4:]
|
||||
} else if len(payload) >= 4 {
|
||||
// FLACSpecificBox starts with a full-box version/flags field.
|
||||
payload = payload[4:]
|
||||
}
|
||||
|
||||
for len(payload) >= 4 {
|
||||
blockType := payload[0] & 0x7F
|
||||
blockLen := int(payload[1])<<16 | int(payload[2])<<8 | int(payload[3])
|
||||
if blockLen < 0 || len(payload) < 4+blockLen {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
block := payload[4 : 4+blockLen]
|
||||
if blockType == 0 && len(block) >= 34 {
|
||||
bitDepth, sampleRate, totalSamples := parseFLACStreamInfoQuality(block[:34])
|
||||
return bitDepth, sampleRate, totalSamples, bitDepth > 0 || sampleRate > 0
|
||||
}
|
||||
payload = payload[4+blockLen:]
|
||||
}
|
||||
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
|
||||
func parseFLACStreamInfoQuality(streamInfo []byte) (int, int, int64) {
|
||||
if len(streamInfo) < 18 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
||||
bitsPerSample := (((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4)) + 1
|
||||
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
|
||||
int64(streamInfo[14])<<24 |
|
||||
int64(streamInfo[15])<<16 |
|
||||
int64(streamInfo[16])<<8 |
|
||||
int64(streamInfo[17])
|
||||
return bitsPerSample, sampleRate, totalSamples
|
||||
}
|
||||
|
||||
func parseALACSpecificConfig(payload []byte) (int, int, bool) {
|
||||
if len(payload) < 24 {
|
||||
return 0, 0, false
|
||||
@@ -1882,8 +2084,14 @@ func findAtomInRange(f *os.File, start, size int64, target string, fileSize int6
|
||||
|
||||
func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string, error) {
|
||||
const chunkSize = 64 * 1024
|
||||
patternMP4A := []byte("mp4a")
|
||||
patternALAC := []byte("alac")
|
||||
patterns := [][]byte{
|
||||
[]byte("mp4a"),
|
||||
[]byte("alac"),
|
||||
[]byte("fLaC"),
|
||||
[]byte("ec-3"),
|
||||
[]byte("ac-3"),
|
||||
[]byte("ac-4"),
|
||||
}
|
||||
|
||||
var tail []byte
|
||||
readPos := start
|
||||
@@ -1904,26 +2112,14 @@ func findAudioSampleEntry(f *os.File, start, end, fileSize int64) (int64, string
|
||||
}
|
||||
|
||||
data := append(tail, buf[:n]...)
|
||||
mp4aIdx := bytes.Index(data, patternMP4A)
|
||||
alacIdx := bytes.Index(data, patternALAC)
|
||||
|
||||
bestIdx := -1
|
||||
bestType := ""
|
||||
switch {
|
||||
case mp4aIdx >= 0 && alacIdx >= 0:
|
||||
if mp4aIdx <= alacIdx {
|
||||
bestIdx = mp4aIdx
|
||||
bestType = "mp4a"
|
||||
} else {
|
||||
bestIdx = alacIdx
|
||||
bestType = "alac"
|
||||
for _, pattern := range patterns {
|
||||
idx := bytes.Index(data, pattern)
|
||||
if idx >= 0 && (bestIdx < 0 || idx < bestIdx) {
|
||||
bestIdx = idx
|
||||
bestType = string(pattern)
|
||||
}
|
||||
case mp4aIdx >= 0:
|
||||
bestIdx = mp4aIdx
|
||||
bestType = "mp4a"
|
||||
case alacIdx >= 0:
|
||||
bestIdx = alacIdx
|
||||
bestType = "alac"
|
||||
}
|
||||
|
||||
if bestIdx >= 0 {
|
||||
|
||||
@@ -47,3 +47,53 @@ func TestParseALACSpecificConfigRejectsShortPayload(t *testing.T) {
|
||||
t.Fatal("expected short ALAC payload to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestM4ACodecFormatMapping(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"mp4a": "aac",
|
||||
"alac": "alac",
|
||||
"fLaC": "flac",
|
||||
"ec-3": "eac3",
|
||||
"ac-3": "ac3",
|
||||
"ac-4": "ac4",
|
||||
}
|
||||
for atomType, want := range cases {
|
||||
if got := normalizeM4AAudioCodec(atomType); got != want {
|
||||
t.Fatalf("normalizeM4AAudioCodec(%q) = %q, want %q", atomType, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
if got := libraryFormatForM4ACodec("flac"); got != "flac" {
|
||||
t.Fatalf("libraryFormatForM4ACodec(flac) = %q", got)
|
||||
}
|
||||
if got := libraryFormatForM4ACodec("eac3"); got != "eac3" {
|
||||
t.Fatalf("libraryFormatForM4ACodec(eac3) = %q", got)
|
||||
}
|
||||
if got := libraryFormatForM4ACodec("aac"); got != "m4a" {
|
||||
t.Fatalf("libraryFormatForM4ACodec(aac) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMP4FLACSpecificConfig(t *testing.T) {
|
||||
streamInfo := make([]byte, 34)
|
||||
sampleRate := 48000
|
||||
bitsPerSample := 24
|
||||
totalSamples := int64(48000 * 180)
|
||||
streamInfo[10] = byte(sampleRate >> 12)
|
||||
streamInfo[11] = byte(sampleRate >> 4)
|
||||
streamInfo[12] = byte((sampleRate&0x0F)<<4 | ((bitsPerSample-1)>>4)&0x01)
|
||||
streamInfo[13] = byte(((bitsPerSample-1)&0x0F)<<4 | int((totalSamples>>32)&0x0F))
|
||||
streamInfo[14] = byte(totalSamples >> 24)
|
||||
streamInfo[15] = byte(totalSamples >> 16)
|
||||
streamInfo[16] = byte(totalSamples >> 8)
|
||||
streamInfo[17] = byte(totalSamples)
|
||||
|
||||
payload := append([]byte{0, 0, 0, 0, 0, 0, 0, 34}, streamInfo...)
|
||||
bitDepth, parsedRate, parsedSamples, ok := parseMP4FLACSpecificConfig(payload)
|
||||
if !ok {
|
||||
t.Fatal("expected MP4 FLAC config to parse")
|
||||
}
|
||||
if bitDepth != bitsPerSample || parsedRate != sampleRate || parsedSamples != totalSamples {
|
||||
t.Fatalf("FLAC config = %d/%d/%d", bitDepth, parsedRate, parsedSamples)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const isrgRootX1PEM = `-----BEGIN CERTIFICATE-----
|
||||
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
|
||||
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
|
||||
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
|
||||
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
|
||||
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
|
||||
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
|
||||
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
|
||||
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
|
||||
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
|
||||
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
|
||||
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
|
||||
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
|
||||
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
|
||||
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
|
||||
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
|
||||
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
|
||||
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
|
||||
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
|
||||
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
|
||||
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
|
||||
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
|
||||
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
|
||||
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
|
||||
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
|
||||
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
|
||||
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
|
||||
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
|
||||
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
const isrgRootX2PEM = `-----BEGIN CERTIFICATE-----
|
||||
MIICGzCCAaGgAwIBAgIQQdKd0XLq7qeAwSxs6S+HUjAKBggqhkjOPQQDAzBPMQsw
|
||||
CQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2gg
|
||||
R3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMjAeFw0yMDA5MDQwMDAwMDBaFw00
|
||||
MDA5MTcxNjAwMDBaME8xCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBT
|
||||
ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgyMHYw
|
||||
EAYHKoZIzj0CAQYFK4EEACIDYgAEzZvVn4CDCuwJSvMWSj5cz3es3mcFDR0HttwW
|
||||
+1qLFNvicWDEukWVEYmO6gbf9yoWHKS5xcUy4APgHoIYOIvXRdgKam7mAHf7AlF9
|
||||
ItgKbppbd9/w+kHsOdx1ymgHDB/qo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0T
|
||||
AQH/BAUwAwEB/zAdBgNVHQ4EFgQUfEKWrt5LSDv6kviejM9ti6lyN5UwCgYIKoZI
|
||||
zj0EAwMDaAAwZQIwe3lORlCEwkSHRhtFcP9Ymd70/aTSVaYgLXTWNLxBo1BfASdW
|
||||
tL4ndQavEi51mI38AjEAi/V3bNTIZargCyzuFJ0nN6T5U6VR5CmD1/iQMVtCnwr1
|
||||
/q4AaOeMSQ+2b1tbFfLn
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
var (
|
||||
supplementalRootCAsOnce sync.Once
|
||||
supplementalRootCAsPool *x509.CertPool
|
||||
)
|
||||
|
||||
func supplementalRootCAs() *x509.CertPool {
|
||||
supplementalRootCAsOnce.Do(func() {
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil || pool == nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
|
||||
for _, pem := range []string{isrgRootX1PEM, isrgRootX2PEM} {
|
||||
pool.AppendCertsFromPEM([]byte(pem))
|
||||
}
|
||||
supplementalRootCAsPool = pool
|
||||
})
|
||||
|
||||
return supplementalRootCAsPool
|
||||
}
|
||||
|
||||
func newTLSCompatibilityConfig(insecureTLS bool) *tls.Config {
|
||||
return &tls.Config{
|
||||
RootCAs: supplementalRootCAs(),
|
||||
InsecureSkipVerify: insecureTLS,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,975 @@
|
||||
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
|
||||
)
|
||||
|
||||
// ---------- low-level chunk size helpers ----------
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// ---------- WAV (RIFF) ----------
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ---------- AIFF / AIFC ----------
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ---------- ID3v2 reading from a buffered chunk ----------
|
||||
|
||||
// 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, ""
|
||||
}
|
||||
|
||||
// ---------- ID3v2.4 building ----------
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// ---------- tag writing (streaming chunk rewrite) ----------
|
||||
|
||||
// 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) {
|
||||
// Drop the existing tag chunk.
|
||||
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
|
||||
}
|
||||
|
||||
// Append the new tag chunk.
|
||||
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)
|
||||
}
|
||||
|
||||
// ---------- library scan integration ----------
|
||||
|
||||
func scanWAVFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
if metadata, err := ReadWAVTags(filePath); err == nil && metadata != nil {
|
||||
applyAudioMetadataToScan(metadata, result)
|
||||
}
|
||||
if quality, err := GetWAVQuality(filePath); err == nil && quality != nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
result.Duration = quality.Duration
|
||||
}
|
||||
result.Bitrate = 0 // lossless PCM
|
||||
result.Format = "wav"
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func scanAIFFFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
|
||||
if metadata, err := ReadAIFFTags(filePath); err == nil && metadata != nil {
|
||||
applyAudioMetadataToScan(metadata, result)
|
||||
}
|
||||
if quality, err := GetAIFFQuality(filePath); err == nil && quality != nil {
|
||||
result.BitDepth = quality.BitDepth
|
||||
result.SampleRate = quality.SampleRate
|
||||
result.Duration = quality.Duration
|
||||
}
|
||||
result.Bitrate = 0 // lossless PCM
|
||||
result.Format = "aiff"
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func applyAudioMetadataToScan(metadata *AudioMetadata, result *LibraryScanResult) {
|
||||
result.TrackName = metadata.Title
|
||||
result.ArtistName = metadata.Artist
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
if metadata.Date != "" {
|
||||
result.ReleaseDate = metadata.Date
|
||||
} else {
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.Genre = metadata.Genre
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
}
|
||||
|
||||
// extractWAVAIFFCover returns embedded cover art (from the ID3 chunk) for a
|
||||
// WAV or AIFF file, or an error when none is present.
|
||||
func extractWAVAIFFCover(filePath string) ([]byte, string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var id3 []byte
|
||||
switch ext {
|
||||
case ".aiff", ".aif", ".aifc":
|
||||
if p, perr := streamProbeAIFF(f); perr == nil {
|
||||
id3 = p.id3
|
||||
}
|
||||
default:
|
||||
if p, perr := streamProbeWAV(f); perr == nil {
|
||||
id3 = p.id3
|
||||
}
|
||||
}
|
||||
if len(id3) == 0 {
|
||||
return nil, "", fmt.Errorf("no embedded cover")
|
||||
}
|
||||
data, mime := extractAPICFromID3(id3)
|
||||
if len(data) == 0 {
|
||||
return nil, "", fmt.Errorf("no embedded cover")
|
||||
}
|
||||
return data, mime, nil
|
||||
}
|
||||
@@ -21,6 +21,6 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
<string>14.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -346,7 +346,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -472,7 +472,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -523,7 +523,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
|
||||
@@ -20,6 +20,11 @@ import Gobackend // Import Go framework
|
||||
|
||||
/// 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,
|
||||
@@ -233,6 +238,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 +265,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?
|
||||
|
||||
@@ -114,6 +114,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.0';
|
||||
static const String buildNumber = '127';
|
||||
static const String version = '4.6.0';
|
||||
static const String buildNumber = '135';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
||||
@@ -17,6 +17,8 @@ class AppInfo {
|
||||
static const String githubUrl = 'https://github.com/$githubRepo';
|
||||
static const String originalGithubUrl =
|
||||
'https://github.com/afkarxyz/SpotiFLAC';
|
||||
static const String remoteConfigApiUrl =
|
||||
'https://api.zarz.moe/v1/spotiflac-mobile/config';
|
||||
|
||||
static const String kofiUrl = 'https://ko-fi.com/zarzet';
|
||||
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
|
||||
|
||||
@@ -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 =>
|
||||
@@ -397,7 +421,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -742,6 +766,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
|
||||
@@ -950,7 +977,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';
|
||||
@@ -1220,6 +1247,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => 'Copy lyrics';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@@ -1338,10 +1370,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';
|
||||
@@ -1515,7 +1548,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';
|
||||
@@ -1523,6 +1556,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@@ -1895,7 +1935,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) {
|
||||
@@ -2078,7 +2118,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 =>
|
||||
@@ -2408,7 +2448,7 @@ class AppLocalizationsEn 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,7 +2816,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) {
|
||||
@@ -2818,6 +2858,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';
|
||||
|
||||
@@ -2972,11 +3016,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@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';
|
||||
@@ -3015,6 +3059,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get downloadAppleQqMultiPersonDisabled =>
|
||||
'Standard lyrics without speaker labels';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSync => 'Apple Music eLRC Word Sync';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguage => 'Musixmatch Language';
|
||||
|
||||
@@ -3260,6 +3315,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@@ -3284,9 +3348,33 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
@@ -3611,6 +3699,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -3804,4 +3899,379 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectNone => 'None';
|
||||
|
||||
@override
|
||||
String queueRetryAllFailed(int count) {
|
||||
return 'Retry $count failed';
|
||||
}
|
||||
|
||||
@override
|
||||
String get settingsSaveDownloadHistory => 'Save download history';
|
||||
|
||||
@override
|
||||
String get settingsSaveDownloadHistorySubtitle =>
|
||||
'Keep completed downloads in history and library views';
|
||||
|
||||
@override
|
||||
String get dialogDisableHistoryTitle => 'Turn off download history?';
|
||||
|
||||
@override
|
||||
String get dialogDisableHistoryMessage =>
|
||||
'Existing history will be cleared. Downloaded files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get dialogDisableAndClear => 'Turn off and clear';
|
||||
|
||||
@override
|
||||
String get openInOtherServices => 'Open in Other Services';
|
||||
|
||||
@override
|
||||
String get shareSheetNoExtensions => 'No other compatible services';
|
||||
|
||||
@override
|
||||
String get shareSheetNotFound => 'Not found';
|
||||
|
||||
@override
|
||||
String get shareSheetCopyLink => 'Copy Link';
|
||||
|
||||
@override
|
||||
String shareSheetLinkCopied(Object service) {
|
||||
return '$service link copied';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
@@ -397,7 +421,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -742,6 +766,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
|
||||
@@ -950,7 +977,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';
|
||||
@@ -1118,10 +1145,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';
|
||||
@@ -1220,6 +1247,11 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => 'Copy lyrics';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@@ -1338,10 +1370,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';
|
||||
@@ -1515,7 +1548,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';
|
||||
@@ -1523,6 +1556,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@@ -2078,7 +2118,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 =>
|
||||
@@ -2408,7 +2448,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';
|
||||
@@ -2761,14 +2801,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 =>
|
||||
@@ -2776,7 +2816,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) {
|
||||
@@ -2818,6 +2858,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';
|
||||
|
||||
@@ -2836,10 +2880,10 @@ 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 tooltipLoveAll => 'Love All';
|
||||
@@ -2899,20 +2943,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(
|
||||
@@ -2924,62 +2968,62 @@ 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 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 =>
|
||||
@@ -2987,11 +3031,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 =>
|
||||
@@ -2999,66 +3043,76 @@ 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';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
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';
|
||||
@@ -3261,6 +3315,15 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@@ -3285,9 +3348,33 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
@@ -3411,7 +3498,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
|
||||
@@ -3606,6 +3699,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -3799,4 +3899,379 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectNone => 'None';
|
||||
|
||||
@override
|
||||
String queueRetryAllFailed(int count) {
|
||||
return 'Retry $count failed';
|
||||
}
|
||||
|
||||
@override
|
||||
String get settingsSaveDownloadHistory => 'Save download history';
|
||||
|
||||
@override
|
||||
String get settingsSaveDownloadHistorySubtitle =>
|
||||
'Keep completed downloads in history and library views';
|
||||
|
||||
@override
|
||||
String get dialogDisableHistoryTitle => 'Turn off download history?';
|
||||
|
||||
@override
|
||||
String get dialogDisableHistoryMessage =>
|
||||
'Existing history will be cleared. Downloaded files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get dialogDisableAndClear => 'Turn off and clear';
|
||||
|
||||
@override
|
||||
String get openInOtherServices => 'Open in Other Services';
|
||||
|
||||
@override
|
||||
String get shareSheetNoExtensions => 'No other compatible services';
|
||||
|
||||
@override
|
||||
String get shareSheetNotFound => 'Not found';
|
||||
|
||||
@override
|
||||
String get shareSheetCopyLink => 'Copy Link';
|
||||
|
||||
@override
|
||||
String shareSheetLinkCopied(Object service) {
|
||||
return '$service link copied';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
@@ -393,7 +418,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'アルバム';
|
||||
@@ -737,6 +762,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'トラックがありません';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
|
||||
@@ -944,7 +972,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 => '拡張';
|
||||
@@ -1112,10 +1140,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 => 'ダウンロードプロバイダーを管理';
|
||||
@@ -1214,6 +1242,11 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => '歌詞をコピー';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'このトラックの歌詞は利用できません';
|
||||
|
||||
@@ -1332,10 +1365,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 => '作者';
|
||||
@@ -1505,7 +1539,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';
|
||||
@@ -1513,6 +1547,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@@ -2065,7 +2106,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 =>
|
||||
@@ -2395,7 +2436,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 => 'オーディオを変換';
|
||||
@@ -2748,14 +2789,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 =>
|
||||
@@ -2763,7 +2804,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) {
|
||||
@@ -2805,6 +2846,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';
|
||||
|
||||
@@ -2823,10 +2868,10 @@ 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 tooltipLoveAll => 'Love All';
|
||||
@@ -2886,20 +2931,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(
|
||||
@@ -2911,62 +2956,62 @@ 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 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 =>
|
||||
@@ -2974,11 +3019,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 =>
|
||||
@@ -2986,66 +3031,76 @@ 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';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
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';
|
||||
@@ -3248,6 +3303,15 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@@ -3272,9 +3336,33 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
@@ -3398,7 +3486,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
|
||||
@@ -3593,6 +3687,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -3786,4 +3887,379 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectNone => 'None';
|
||||
|
||||
@override
|
||||
String queueRetryAllFailed(int count) {
|
||||
return 'Retry $count failed';
|
||||
}
|
||||
|
||||
@override
|
||||
String get settingsSaveDownloadHistory => 'Save download history';
|
||||
|
||||
@override
|
||||
String get settingsSaveDownloadHistorySubtitle =>
|
||||
'Keep completed downloads in history and library views';
|
||||
|
||||
@override
|
||||
String get dialogDisableHistoryTitle => 'Turn off download history?';
|
||||
|
||||
@override
|
||||
String get dialogDisableHistoryMessage =>
|
||||
'Existing history will be cleared. Downloaded files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get dialogDisableAndClear => 'Turn off and clear';
|
||||
|
||||
@override
|
||||
String get openInOtherServices => 'Open in Other Services';
|
||||
|
||||
@override
|
||||
String get shareSheetNoExtensions => 'No other compatible services';
|
||||
|
||||
@override
|
||||
String get shareSheetNotFound => 'Not found';
|
||||
|
||||
@override
|
||||
String get shareSheetCopyLink => 'Copy Link';
|
||||
|
||||
@override
|
||||
String shareSheetLinkCopied(Object service) {
|
||||
return '$service link copied';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
@@ -385,7 +414,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => '앨범';
|
||||
@@ -724,6 +753,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => '트랙을 찾을 수 없습니다';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
|
||||
@@ -826,7 +858,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get tooltipPlay => '재생';
|
||||
|
||||
@override
|
||||
String get filenameFormat => '';
|
||||
String get filenameFormat => 'Filename Format';
|
||||
|
||||
@override
|
||||
String get filenameShowAdvancedTags => '고급 태그 표시';
|
||||
@@ -932,7 +964,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';
|
||||
@@ -1098,10 +1130,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';
|
||||
@@ -1200,6 +1232,11 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => 'Copy lyrics';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@@ -1318,10 +1355,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';
|
||||
@@ -1495,7 +1533,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';
|
||||
@@ -1503,6 +1541,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@@ -2058,7 +2103,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 =>
|
||||
@@ -2388,7 +2433,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';
|
||||
@@ -2741,14 +2786,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 =>
|
||||
@@ -2756,7 +2801,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) {
|
||||
@@ -2798,6 +2843,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';
|
||||
|
||||
@@ -2816,10 +2865,10 @@ 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 tooltipLoveAll => 'Love All';
|
||||
@@ -2879,20 +2928,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(
|
||||
@@ -2904,62 +2953,62 @@ 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 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 =>
|
||||
@@ -2967,11 +3016,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 =>
|
||||
@@ -2979,66 +3028,76 @@ 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';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
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';
|
||||
@@ -3241,6 +3300,15 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@@ -3265,9 +3333,33 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
@@ -3391,7 +3483,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
|
||||
@@ -3586,6 +3684,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -3779,4 +3884,379 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectNone => 'None';
|
||||
|
||||
@override
|
||||
String queueRetryAllFailed(int count) {
|
||||
return 'Retry $count failed';
|
||||
}
|
||||
|
||||
@override
|
||||
String get settingsSaveDownloadHistory => 'Save download history';
|
||||
|
||||
@override
|
||||
String get settingsSaveDownloadHistorySubtitle =>
|
||||
'Keep completed downloads in history and library views';
|
||||
|
||||
@override
|
||||
String get dialogDisableHistoryTitle => 'Turn off download history?';
|
||||
|
||||
@override
|
||||
String get dialogDisableHistoryMessage =>
|
||||
'Existing history will be cleared. Downloaded files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get dialogDisableAndClear => 'Turn off and clear';
|
||||
|
||||
@override
|
||||
String get openInOtherServices => 'Open in Other Services';
|
||||
|
||||
@override
|
||||
String get shareSheetNoExtensions => 'No other compatible services';
|
||||
|
||||
@override
|
||||
String get shareSheetNotFound => 'Not found';
|
||||
|
||||
@override
|
||||
String get shareSheetCopyLink => 'Copy Link';
|
||||
|
||||
@override
|
||||
String shareSheetLinkCopied(Object service) {
|
||||
return '$service link copied';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
@@ -397,7 +421,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Download Spotify tracks in lossless quality from Tidal and Qobuz.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Albums';
|
||||
@@ -742,6 +766,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'No tracks found';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link not recognized';
|
||||
|
||||
@@ -950,7 +977,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';
|
||||
@@ -1118,10 +1145,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';
|
||||
@@ -1220,6 +1247,11 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => 'Copy lyrics';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable => 'Lyrics not available for this track';
|
||||
|
||||
@@ -1338,10 +1370,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';
|
||||
@@ -1515,7 +1548,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';
|
||||
@@ -1523,6 +1556,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@@ -2078,7 +2118,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 =>
|
||||
@@ -2408,7 +2448,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';
|
||||
@@ -2761,14 +2801,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 =>
|
||||
@@ -2776,7 +2816,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) {
|
||||
@@ -2818,6 +2858,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';
|
||||
|
||||
@@ -2836,10 +2880,10 @@ 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 tooltipLoveAll => 'Love All';
|
||||
@@ -2899,20 +2943,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(
|
||||
@@ -2924,62 +2968,62 @@ 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 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 =>
|
||||
@@ -2987,11 +3031,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 =>
|
||||
@@ -2999,66 +3043,76 @@ 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';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
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';
|
||||
@@ -3261,6 +3315,15 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
|
||||
@@ -3285,9 +3348,33 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Search with $providerName';
|
||||
@@ -3411,7 +3498,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
|
||||
@@ -3606,6 +3699,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -3799,4 +3899,379 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectNone => 'None';
|
||||
|
||||
@override
|
||||
String queueRetryAllFailed(int count) {
|
||||
return 'Retry $count failed';
|
||||
}
|
||||
|
||||
@override
|
||||
String get settingsSaveDownloadHistory => 'Save download history';
|
||||
|
||||
@override
|
||||
String get settingsSaveDownloadHistorySubtitle =>
|
||||
'Keep completed downloads in history and library views';
|
||||
|
||||
@override
|
||||
String get dialogDisableHistoryTitle => 'Turn off download history?';
|
||||
|
||||
@override
|
||||
String get dialogDisableHistoryMessage =>
|
||||
'Existing history will be cleared. Downloaded files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get dialogDisableAndClear => 'Turn off and clear';
|
||||
|
||||
@override
|
||||
String get openInOtherServices => 'Open in Other Services';
|
||||
|
||||
@override
|
||||
String get shareSheetNoExtensions => 'No other compatible services';
|
||||
|
||||
@override
|
||||
String get shareSheetNotFound => 'Not found';
|
||||
|
||||
@override
|
||||
String get shareSheetCopyLink => 'Copy Link';
|
||||
|
||||
@override
|
||||
String shareSheetLinkCopied(Object service) {
|
||||
return '$service link copied';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
@@ -407,7 +429,7 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get aboutAppDescription =>
|
||||
'Кінцеві точки потокового передавання Tidal Hi-Res FLAC. Ключовий елемент пазлу музики без втрат.';
|
||||
'Search music metadata, manage extensions, and organize your library.';
|
||||
|
||||
@override
|
||||
String get artistAlbums => 'Альбоми';
|
||||
@@ -755,6 +777,9 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get errorNoTracksFound => 'Треків не знайдено';
|
||||
|
||||
@override
|
||||
String get searchEmptyResultSubtitle => 'Try another keyword';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Посилання не розпізнано';
|
||||
|
||||
@@ -956,14 +981,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 => 'Розширення';
|
||||
@@ -1134,11 +1159,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 =>
|
||||
@@ -1240,6 +1264,11 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get trackCopyLyrics => 'Скопіювати тексти пісень';
|
||||
|
||||
@override
|
||||
String trackLyricsSource(String source) {
|
||||
return 'Source: $source';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsNotAvailable =>
|
||||
'Текст пісні для цього треку недоступний';
|
||||
@@ -1360,10 +1389,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 => 'Автор';
|
||||
@@ -1539,7 +1569,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 кбіт/с';
|
||||
@@ -1548,6 +1578,13 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
String get downloadLossyMp3Subtitle =>
|
||||
'Найкраща сумісність, ~10 МБ на доріжку';
|
||||
|
||||
@override
|
||||
String get downloadLossyAac => 'AAC/M4A 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyAacSubtitle =>
|
||||
'Best mobile compatibility, M4A container';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256 кбіт/с';
|
||||
|
||||
@@ -2114,7 +2151,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 =>
|
||||
@@ -2449,7 +2486,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 => 'Конвертувати аудіо';
|
||||
@@ -2806,14 +2843,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 =>
|
||||
@@ -2821,7 +2858,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) {
|
||||
@@ -2865,6 +2902,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 => 'Постачальник розширень';
|
||||
|
||||
@@ -2883,11 +2924,10 @@ 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 tooltipLoveAll => 'Уподобати всіх';
|
||||
@@ -2950,21 +2990,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(
|
||||
@@ -2976,73 +3015,73 @@ 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 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 =>
|
||||
@@ -3050,67 +3089,76 @@ 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';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncEnabled =>
|
||||
'Raw word-by-word timestamps preserved';
|
||||
|
||||
@override
|
||||
String get downloadAppleElrcWordSyncDisabled =>
|
||||
'Safer line-by-line Apple Music lyrics';
|
||||
|
||||
@override
|
||||
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 => 'Непідтримуваний аудіоформат';
|
||||
@@ -3316,6 +3364,15 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Частота дискретизації';
|
||||
|
||||
@override
|
||||
String get audioAnalysisCodec => 'Codec';
|
||||
|
||||
@override
|
||||
String get audioAnalysisContainer => 'Container';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDecodedFormat => 'Decoded Format';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Глибина бітів';
|
||||
|
||||
@@ -3340,9 +3397,33 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisLufs => 'LUFS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTruePeak => 'True Peak';
|
||||
|
||||
@override
|
||||
String get audioAnalysisClipping => 'Clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNoClipping => 'No clipping';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSpectralCutoff => 'Spectral Cutoff';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannelStats => 'Per-channel Stats';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Семпли';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescan => 'Re-analyze';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRescanning => 'Re-analyzing audio...';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
return 'Пошук за допомогою$providerName';
|
||||
@@ -3470,7 +3551,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
|
||||
@@ -3550,7 +3637,7 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String notifDownloadingUpdate(String version) {
|
||||
return 'Завантаження SpotiFLAC Mobile v$version';
|
||||
return 'Downloading SpotiFLAC Mobile v$version';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -3563,7 +3650,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
|
||||
@@ -3665,6 +3752,13 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get queueDownloadCompleted => 'Download completed';
|
||||
|
||||
@override
|
||||
String get queueRateLimitTitle => 'Service rate limited';
|
||||
|
||||
@override
|
||||
String get queueRateLimitMessage =>
|
||||
'This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.';
|
||||
|
||||
@override
|
||||
String appearanceSelectAccentColor(String hex) {
|
||||
return 'Select accent color $hex';
|
||||
@@ -3858,4 +3952,379 @@ class AppLocalizationsUk extends AppLocalizations {
|
||||
@override
|
||||
String get downloadFallbackExtensionsSubtitle =>
|
||||
'Choose which extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDateHint => 'YYYY-MM-DD or YYYY';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackTotal => 'Track Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscTotal => 'Disc Total';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComposer => 'Composer';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldComment => 'Comment';
|
||||
|
||||
@override
|
||||
String get editMetadataAdvanced => 'Advanced';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingTrackNumber => 'Missing track number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingDiscNumber => 'Missing disc number';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingArtist => 'Missing artist';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataIncorrectIsrcFormat =>
|
||||
'Incorrect ISRC format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingLabel => 'Missing label';
|
||||
|
||||
@override
|
||||
String collectionDeletePlaylistsMessage(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return 'Delete $count $_temp0?';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionPlaylistsDeleted(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
);
|
||||
return '$count $_temp0 deleted';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylist(int count, String playlistName) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String collectionAddedTracksToPlaylistWithExisting(
|
||||
int count,
|
||||
String playlistName,
|
||||
int alreadyCount,
|
||||
) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Added $count $_temp0 to $playlistName ($alreadyCount already in playlist)';
|
||||
}
|
||||
|
||||
@override
|
||||
String itemCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'items',
|
||||
one: 'item',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String trackReEnrichSuccessWithFailures(
|
||||
int successCount,
|
||||
int total,
|
||||
int failedCount,
|
||||
) {
|
||||
return 'Metadata re-enriched successfully ($successCount/$total) - Failed: $failedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
String selectionDeleteTracksCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
);
|
||||
return 'Delete $count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueDownloadSpeedStatus(String speed) {
|
||||
return 'Downloading - $speed MB/s';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadStarting => 'Starting...';
|
||||
|
||||
@override
|
||||
String get a11ySelectTrack => 'Select track';
|
||||
|
||||
@override
|
||||
String get a11yDeselectTrack => 'Deselect track';
|
||||
|
||||
@override
|
||||
String a11yPlayTrackByArtist(String trackName, String artistName) {
|
||||
return 'Play $trackName by $artistName';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeExtensionsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'extensions',
|
||||
one: 'extension',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String storeRequiresVersion(String version) {
|
||||
return 'Requires v$version+';
|
||||
}
|
||||
|
||||
@override
|
||||
String get actionGo => 'Go';
|
||||
|
||||
@override
|
||||
String get logIssueSummary => 'Issue Summary';
|
||||
|
||||
@override
|
||||
String logTotalErrors(int count) {
|
||||
return 'Total errors: $count';
|
||||
}
|
||||
|
||||
@override
|
||||
String logAffectedDomains(String domains) {
|
||||
return 'Affected: $domains';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryScanCancelled => 'Scan cancelled';
|
||||
|
||||
@override
|
||||
String get libraryScanCancelledSubtitle =>
|
||||
'You can retry the scan when ready.';
|
||||
|
||||
@override
|
||||
String libraryDownloadsHistoryExcluded(int count) {
|
||||
return '$count from Downloads history (excluded from list)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get downloadNativeWorker => 'Native download worker';
|
||||
|
||||
@override
|
||||
String get downloadNativeWorkerSubtitle =>
|
||||
'Beta Android service worker for extension downloads';
|
||||
|
||||
@override
|
||||
String get badgeBeta => 'BETA';
|
||||
|
||||
@override
|
||||
String get extensionServiceStatus => 'Service Status';
|
||||
|
||||
@override
|
||||
String get extensionServiceHealth => 'Service health';
|
||||
|
||||
@override
|
||||
String extensionHealthChecksConfigured(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'checks',
|
||||
one: 'check',
|
||||
);
|
||||
return '$count $_temp0 configured';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionOauthConnectHint =>
|
||||
'Tap Connect to Spotify to fill this field.';
|
||||
|
||||
@override
|
||||
String extensionLastChecked(String time) {
|
||||
return 'Last checked $time';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionRefreshStatus => 'Refresh status';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandling => 'Custom URL Handling';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingSubtitle =>
|
||||
'This extension can handle links from these sites';
|
||||
|
||||
@override
|
||||
String get extensionCustomUrlHandlingShareHint =>
|
||||
'Share links from these sites to SpotiFLAC Mobile and this extension will handle them.';
|
||||
|
||||
@override
|
||||
String extensionSettingsCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'settings',
|
||||
one: 'setting',
|
||||
);
|
||||
return '$count $_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionHealthOnline => 'Online';
|
||||
|
||||
@override
|
||||
String get extensionHealthDegraded => 'Degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthOffline => 'Offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthNotConfigured => 'Not configured';
|
||||
|
||||
@override
|
||||
String get extensionHealthUnknown => 'Unknown';
|
||||
|
||||
@override
|
||||
String get extensionHealthRequired => 'required';
|
||||
|
||||
@override
|
||||
String get extensionSettingNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get extensionActionFailed => 'Action failed';
|
||||
|
||||
@override
|
||||
String get extensionEnterValue => 'Enter value';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOnline => 'Service online';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceDegraded => 'Service degraded';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceOffline => 'Service offline';
|
||||
|
||||
@override
|
||||
String get extensionHealthServiceUnknown => 'Service status unknown';
|
||||
|
||||
@override
|
||||
String get audioAnalysisStereo => 'Stereo';
|
||||
|
||||
@override
|
||||
String get audioAnalysisMono => 'Mono';
|
||||
|
||||
@override
|
||||
String trackOpenInService(String serviceName) {
|
||||
return 'Open in $serviceName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackLyricsEmbeddedSource => 'Embedded';
|
||||
|
||||
@override
|
||||
String get unknownAlbum => 'Unknown Album';
|
||||
|
||||
@override
|
||||
String get unknownArtist => 'Unknown Artist';
|
||||
|
||||
@override
|
||||
String get permissionAudio => 'Audio';
|
||||
|
||||
@override
|
||||
String get permissionStorage => 'Storage';
|
||||
|
||||
@override
|
||||
String get permissionNotification => 'Notification';
|
||||
|
||||
@override
|
||||
String get errorInvalidFolderSelected => 'Invalid folder selected';
|
||||
|
||||
@override
|
||||
String get errorCouldNotKeepFolderAccess =>
|
||||
'Could not keep access to the selected folder';
|
||||
|
||||
@override
|
||||
String get storeAnyVersion => 'Any';
|
||||
|
||||
@override
|
||||
String get storeCategoryMetadata => 'Metadata';
|
||||
|
||||
@override
|
||||
String get storeCategoryDownload => 'Download';
|
||||
|
||||
@override
|
||||
String get storeCategoryUtility => 'Utility';
|
||||
|
||||
@override
|
||||
String get storeCategoryLyrics => 'Lyrics';
|
||||
|
||||
@override
|
||||
String get storeCategoryIntegration => 'Integration';
|
||||
|
||||
@override
|
||||
String get artistReleases => 'Releases';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectNone => 'None';
|
||||
|
||||
@override
|
||||
String queueRetryAllFailed(int count) {
|
||||
return 'Retry $count failed';
|
||||
}
|
||||
|
||||
@override
|
||||
String get settingsSaveDownloadHistory => 'Save download history';
|
||||
|
||||
@override
|
||||
String get settingsSaveDownloadHistorySubtitle =>
|
||||
'Keep completed downloads in history and library views';
|
||||
|
||||
@override
|
||||
String get dialogDisableHistoryTitle => 'Turn off download history?';
|
||||
|
||||
@override
|
||||
String get dialogDisableHistoryMessage =>
|
||||
'Existing history will be cleared. Downloaded files will not be deleted.';
|
||||
|
||||
@override
|
||||
String get dialogDisableAndClear => 'Turn off and clear';
|
||||
|
||||
@override
|
||||
String get openInOtherServices => 'Open in Other Services';
|
||||
|
||||
@override
|
||||
String get shareSheetNoExtensions => 'No other compatible services';
|
||||
|
||||
@override
|
||||
String get shareSheetNotFound => 'Not found';
|
||||
|
||||
@override
|
||||
String get shareSheetCopyLink => 'Copy Link';
|
||||
|
||||
@override
|
||||
String shareSheetLinkCopied(Object service) {
|
||||
return '$service link copied';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
@@ -498,7 +535,7 @@
|
||||
"@aboutSjdonadoDesc": {
|
||||
"description": "Credit description for sjdonado"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -961,6 +998,10 @@
|
||||
"@errorNoTracksFound": {
|
||||
"description": "Error - search returned no results"
|
||||
},
|
||||
"searchEmptyResultSubtitle": "Try another keyword",
|
||||
"@searchEmptyResultSubtitle": {
|
||||
"description": "Subtitle shown under the empty search result state on the home screen"
|
||||
},
|
||||
"errorUrlNotRecognized": "Link not recognized",
|
||||
"@errorUrlNotRecognized": {
|
||||
"description": "Error title - URL not handled by any extension or service"
|
||||
@@ -1231,9 +1272,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": {
|
||||
@@ -1587,6 +1628,15 @@
|
||||
"@trackCopyLyrics": {
|
||||
"description": "Action - copy lyrics to clipboard"
|
||||
},
|
||||
"trackLyricsSource": "Source: {source}",
|
||||
"@trackLyricsSource": {
|
||||
"description": "Label showing the lyrics source/provider",
|
||||
"placeholders": {
|
||||
"source": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackLyricsNotAvailable": "Lyrics not available for this track",
|
||||
"@trackLyricsNotAvailable": {
|
||||
"description": "Message when lyrics not found"
|
||||
@@ -1746,11 +1796,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"
|
||||
},
|
||||
@@ -1979,43 +2029,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": "Lossy format option - AAC in M4A container at 320kbps"
|
||||
},
|
||||
"downloadLossyAacSubtitle": "Best mobile compatibility, M4A container",
|
||||
"@downloadLossyAacSubtitle": {
|
||||
"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": {
|
||||
@@ -2498,7 +2556,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"
|
||||
},
|
||||
@@ -2724,7 +2782,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"
|
||||
},
|
||||
@@ -3170,7 +3228,7 @@
|
||||
"@trackConvertFormat": {
|
||||
"description": "Menu item - convert audio format"
|
||||
},
|
||||
"trackConvertFormatSubtitle": "Convert to MP3, Opus, ALAC, or FLAC",
|
||||
"trackConvertFormatSubtitle": "Convert to AAC/M4A, MP3, Opus, ALAC, or FLAC",
|
||||
"@trackConvertFormatSubtitle": {
|
||||
"description": "Subtitle for convert format menu item"
|
||||
},
|
||||
@@ -3683,7 +3741,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"
|
||||
},
|
||||
@@ -3737,6 +3795,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"
|
||||
@@ -3921,13 +3983,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": {
|
||||
@@ -3969,6 +4031,18 @@
|
||||
"@downloadAppleQqMultiPersonDisabled": {
|
||||
"description": "Subtitle when multi-person lyrics is off"
|
||||
},
|
||||
"downloadAppleElrcWordSync": "Apple Music eLRC Word Sync",
|
||||
"@downloadAppleElrcWordSync": {
|
||||
"description": "Setting for preserving Apple Music word-by-word eLRC timestamps"
|
||||
},
|
||||
"downloadAppleElrcWordSyncEnabled": "Raw word-by-word timestamps preserved",
|
||||
"@downloadAppleElrcWordSyncEnabled": {
|
||||
"description": "Subtitle when Apple Music eLRC word sync is enabled"
|
||||
},
|
||||
"downloadAppleElrcWordSyncDisabled": "Safer line-by-line Apple Music lyrics",
|
||||
"@downloadAppleElrcWordSyncDisabled": {
|
||||
"description": "Subtitle when Apple Music eLRC word sync is disabled"
|
||||
},
|
||||
"downloadMusixmatchLanguage": "Musixmatch Language",
|
||||
"@downloadMusixmatchLanguage": {
|
||||
"description": "Setting for Musixmatch lyrics translation language"
|
||||
@@ -4243,6 +4317,18 @@
|
||||
"@audioAnalysisSampleRate": {
|
||||
"description": "Sample rate metric label"
|
||||
},
|
||||
"audioAnalysisCodec": "Codec",
|
||||
"@audioAnalysisCodec": {
|
||||
"description": "Audio codec metric label"
|
||||
},
|
||||
"audioAnalysisContainer": "Container",
|
||||
"@audioAnalysisContainer": {
|
||||
"description": "Audio container metric label"
|
||||
},
|
||||
"audioAnalysisDecodedFormat": "Decoded Format",
|
||||
"@audioAnalysisDecodedFormat": {
|
||||
"description": "Decoded sample format metric label"
|
||||
},
|
||||
"audioAnalysisBitDepth": "Bit Depth",
|
||||
"@audioAnalysisBitDepth": {
|
||||
"description": "Bit depth metric label"
|
||||
@@ -4275,13 +4361,45 @@
|
||||
"@audioAnalysisRms": {
|
||||
"description": "RMS level metric label"
|
||||
},
|
||||
"audioAnalysisLufs": "LUFS",
|
||||
"@audioAnalysisLufs": {
|
||||
"description": "Integrated loudness metric label"
|
||||
},
|
||||
"audioAnalysisTruePeak": "True Peak",
|
||||
"@audioAnalysisTruePeak": {
|
||||
"description": "True peak metric label"
|
||||
},
|
||||
"audioAnalysisClipping": "Clipping",
|
||||
"@audioAnalysisClipping": {
|
||||
"description": "Clipping metric label"
|
||||
},
|
||||
"audioAnalysisNoClipping": "No clipping",
|
||||
"@audioAnalysisNoClipping": {
|
||||
"description": "Displayed when no clipped samples were detected"
|
||||
},
|
||||
"audioAnalysisSpectralCutoff": "Spectral Cutoff",
|
||||
"@audioAnalysisSpectralCutoff": {
|
||||
"description": "Estimated spectral cutoff metric label"
|
||||
},
|
||||
"audioAnalysisChannelStats": "Per-channel Stats",
|
||||
"@audioAnalysisChannelStats": {
|
||||
"description": "Per-channel audio analysis section label"
|
||||
},
|
||||
"audioAnalysisSamples": "Samples",
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"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"
|
||||
@@ -4735,6 +4853,14 @@
|
||||
"@queueDownloadCompleted": {
|
||||
"description": "Accessibility label for completed download state in queue"
|
||||
},
|
||||
"queueRateLimitTitle": "Service rate limited",
|
||||
"@queueRateLimitTitle": {
|
||||
"description": "Title shown on a failed queue item when the download service rate limits requests"
|
||||
},
|
||||
"queueRateLimitMessage": "This track may still be available. Wait a few minutes, reduce parallel downloads, then retry.",
|
||||
"@queueRateLimitMessage": {
|
||||
"description": "Explanation shown on a failed queue item when the download service rate limits requests"
|
||||
},
|
||||
"appearanceSelectAccentColor": "Select accent color {hex}",
|
||||
"@appearanceSelectAccentColor": {
|
||||
"description": "Accessibility label for picking an accent color",
|
||||
@@ -5001,5 +5127,473 @@
|
||||
"downloadFallbackExtensionsSubtitle": "Choose which extensions can be used as fallback",
|
||||
"@downloadFallbackExtensionsSubtitle": {
|
||||
"description": "Subtitle for fallback extensions item"
|
||||
},
|
||||
"editMetadataFieldDateHint": "YYYY-MM-DD or YYYY",
|
||||
"@editMetadataFieldDateHint": {
|
||||
"description": "Hint text for the edit metadata date field"
|
||||
},
|
||||
"editMetadataFieldTrackTotal": "Track Total",
|
||||
"@editMetadataFieldTrackTotal": {
|
||||
"description": "Label for total tracks field in the edit metadata sheet"
|
||||
},
|
||||
"editMetadataFieldDiscTotal": "Disc Total",
|
||||
"@editMetadataFieldDiscTotal": {
|
||||
"description": "Label for total discs field in the edit metadata sheet"
|
||||
},
|
||||
"editMetadataFieldComposer": "Composer",
|
||||
"@editMetadataFieldComposer": {
|
||||
"description": "Label for composer field in the edit metadata sheet"
|
||||
},
|
||||
"editMetadataFieldComment": "Comment",
|
||||
"@editMetadataFieldComment": {
|
||||
"description": "Label for comment field in the edit metadata sheet"
|
||||
},
|
||||
"editMetadataAdvanced": "Advanced",
|
||||
"@editMetadataAdvanced": {
|
||||
"description": "Expandable section label for advanced metadata fields"
|
||||
},
|
||||
"libraryFilterMetadataMissingTrackNumber": "Missing track number",
|
||||
"@libraryFilterMetadataMissingTrackNumber": {
|
||||
"description": "Filter option - items missing track number"
|
||||
},
|
||||
"libraryFilterMetadataMissingDiscNumber": "Missing disc number",
|
||||
"@libraryFilterMetadataMissingDiscNumber": {
|
||||
"description": "Filter option - items missing disc number"
|
||||
},
|
||||
"libraryFilterMetadataMissingArtist": "Missing artist",
|
||||
"@libraryFilterMetadataMissingArtist": {
|
||||
"description": "Filter option - items missing artist"
|
||||
},
|
||||
"libraryFilterMetadataIncorrectIsrcFormat": "Incorrect ISRC format",
|
||||
"@libraryFilterMetadataIncorrectIsrcFormat": {
|
||||
"description": "Filter option - items with an invalid ISRC format"
|
||||
},
|
||||
"libraryFilterMetadataMissingLabel": "Missing label",
|
||||
"@libraryFilterMetadataMissingLabel": {
|
||||
"description": "Filter option - items missing record label"
|
||||
},
|
||||
"collectionDeletePlaylistsMessage": "Delete {count} {count, plural, =1{playlist} other{playlists}}?",
|
||||
"@collectionDeletePlaylistsMessage": {
|
||||
"description": "Confirmation message for deleting selected playlists",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionPlaylistsDeleted": "{count} {count, plural, =1{playlist} other{playlists}} deleted",
|
||||
"@collectionPlaylistsDeleted": {
|
||||
"description": "Snackbar after deleting selected playlists",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAddedTracksToPlaylist": "Added {count} {count, plural, =1{track} other{tracks}} to {playlistName}",
|
||||
"@collectionAddedTracksToPlaylist": {
|
||||
"description": "Snackbar after adding multiple tracks to a playlist",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"playlistName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"collectionAddedTracksToPlaylistWithExisting": "Added {count} {count, plural, =1{track} other{tracks}} to {playlistName} ({alreadyCount} already in playlist)",
|
||||
"@collectionAddedTracksToPlaylistWithExisting": {
|
||||
"description": "Snackbar after adding multiple tracks to a playlist when some were already present",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
},
|
||||
"playlistName": {
|
||||
"type": "String"
|
||||
},
|
||||
"alreadyCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"itemCount": "{count} {count, plural, =1{item} other{items}}",
|
||||
"@itemCount": {
|
||||
"description": "Generic item count label",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackReEnrichSuccessWithFailures": "Metadata re-enriched successfully ({successCount}/{total}) - Failed: {failedCount}",
|
||||
"@trackReEnrichSuccessWithFailures": {
|
||||
"description": "Snackbar summary after batch metadata re-enrichment finishes with failures",
|
||||
"placeholders": {
|
||||
"successCount": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
},
|
||||
"failedCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selectionDeleteTracksCount": "Delete {count} {count, plural, =1{track} other{tracks}}",
|
||||
"@selectionDeleteTracksCount": {
|
||||
"description": "Button label for deleting selected tracks",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueDownloadSpeedStatus": "Downloading - {speed} MB/s",
|
||||
"@queueDownloadSpeedStatus": {
|
||||
"description": "Queue status while downloading with speed",
|
||||
"placeholders": {
|
||||
"speed": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queueDownloadStarting": "Starting...",
|
||||
"@queueDownloadStarting": {
|
||||
"description": "Queue status before download progress is available"
|
||||
},
|
||||
"a11ySelectTrack": "Select track",
|
||||
"@a11ySelectTrack": {
|
||||
"description": "Accessibility label for selecting a track"
|
||||
},
|
||||
"a11yDeselectTrack": "Deselect track",
|
||||
"@a11yDeselectTrack": {
|
||||
"description": "Accessibility label for deselecting a track"
|
||||
},
|
||||
"a11yPlayTrackByArtist": "Play {trackName} by {artistName}",
|
||||
"@a11yPlayTrackByArtist": {
|
||||
"description": "Accessibility label for playing a local library track",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
},
|
||||
"artistName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeExtensionsCount": "{count} {count, plural, =1{extension} other{extensions}}",
|
||||
"@storeExtensionsCount": {
|
||||
"description": "Store extension result count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeRequiresVersion": "Requires v{version}+",
|
||||
"@storeRequiresVersion": {
|
||||
"description": "Store compatibility badge for minimum app version",
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"actionGo": "Go",
|
||||
"@actionGo": {
|
||||
"description": "Generic action button label"
|
||||
},
|
||||
"logIssueSummary": "Issue Summary",
|
||||
"@logIssueSummary": {
|
||||
"description": "Header for log issue analysis summary"
|
||||
},
|
||||
"logTotalErrors": "Total errors: {count}",
|
||||
"@logTotalErrors": {
|
||||
"description": "Total error count in log issue analysis",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logAffectedDomains": "Affected: {domains}",
|
||||
"@logAffectedDomains": {
|
||||
"description": "Affected domains in log issue analysis",
|
||||
"placeholders": {
|
||||
"domains": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraryScanCancelled": "Scan cancelled",
|
||||
"@libraryScanCancelled": {
|
||||
"description": "Library scan status when a scan was cancelled"
|
||||
},
|
||||
"libraryScanCancelledSubtitle": "You can retry the scan when ready.",
|
||||
"@libraryScanCancelledSubtitle": {
|
||||
"description": "Library scan status subtitle after cancellation"
|
||||
},
|
||||
"libraryDownloadsHistoryExcluded": "{count} from Downloads history (excluded from list)",
|
||||
"@libraryDownloadsHistoryExcluded": {
|
||||
"description": "Library count note for downloaded history items excluded from the local list",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"downloadNativeWorker": "Native download worker",
|
||||
"@downloadNativeWorker": {
|
||||
"description": "Setting title for Android native download worker"
|
||||
},
|
||||
"downloadNativeWorkerSubtitle": "Beta Android service worker for extension downloads",
|
||||
"@downloadNativeWorkerSubtitle": {
|
||||
"description": "Setting subtitle for Android native download worker"
|
||||
},
|
||||
"badgeBeta": "BETA",
|
||||
"@badgeBeta": {
|
||||
"description": "Badge label for beta features"
|
||||
},
|
||||
"extensionServiceStatus": "Service Status",
|
||||
"@extensionServiceStatus": {
|
||||
"description": "Extension detail section header for service status"
|
||||
},
|
||||
"extensionServiceHealth": "Service health",
|
||||
"@extensionServiceHealth": {
|
||||
"description": "Extension capability label for service health checks"
|
||||
},
|
||||
"extensionHealthChecksConfigured": "{count} {count, plural, =1{check} other{checks}} configured",
|
||||
"@extensionHealthChecksConfigured": {
|
||||
"description": "Extension service health check count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensionOauthConnectHint": "Tap Connect to Spotify to fill this field.",
|
||||
"@extensionOauthConnectHint": {
|
||||
"description": "Hint for an OAuth login link field before connecting Spotify"
|
||||
},
|
||||
"extensionLastChecked": "Last checked {time}",
|
||||
"@extensionLastChecked": {
|
||||
"description": "Timestamp for the latest extension service health check",
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensionRefreshStatus": "Refresh status",
|
||||
"@extensionRefreshStatus": {
|
||||
"description": "Tooltip for refreshing extension service health status"
|
||||
},
|
||||
"extensionCustomUrlHandling": "Custom URL Handling",
|
||||
"@extensionCustomUrlHandling": {
|
||||
"description": "Extension detail section title for custom URL handling"
|
||||
},
|
||||
"extensionCustomUrlHandlingSubtitle": "This extension can handle links from these sites",
|
||||
"@extensionCustomUrlHandlingSubtitle": {
|
||||
"description": "Extension detail subtitle for custom URL handling"
|
||||
},
|
||||
"extensionCustomUrlHandlingShareHint": "Share links from these sites to SpotiFLAC Mobile and this extension will handle them.",
|
||||
"@extensionCustomUrlHandlingShareHint": {
|
||||
"description": "Extension detail hint explaining share-to-app URL handling"
|
||||
},
|
||||
"extensionSettingsCount": "{count} {count, plural, =1{setting} other{settings}}",
|
||||
"@extensionSettingsCount": {
|
||||
"description": "Count of settings exposed by an extension quality option",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensionHealthOnline": "Online",
|
||||
"@extensionHealthOnline": {
|
||||
"description": "Extension service health status - online"
|
||||
},
|
||||
"extensionHealthDegraded": "Degraded",
|
||||
"@extensionHealthDegraded": {
|
||||
"description": "Extension service health status - degraded"
|
||||
},
|
||||
"extensionHealthOffline": "Offline",
|
||||
"@extensionHealthOffline": {
|
||||
"description": "Extension service health status - offline"
|
||||
},
|
||||
"extensionHealthNotConfigured": "Not configured",
|
||||
"@extensionHealthNotConfigured": {
|
||||
"description": "Extension service health status - not configured"
|
||||
},
|
||||
"extensionHealthUnknown": "Unknown",
|
||||
"@extensionHealthUnknown": {
|
||||
"description": "Extension service health status - unknown"
|
||||
},
|
||||
"extensionHealthRequired": "required",
|
||||
"@extensionHealthRequired": {
|
||||
"description": "Label for a required extension service health check"
|
||||
},
|
||||
"extensionSettingNotSet": "Not set",
|
||||
"@extensionSettingNotSet": {
|
||||
"description": "Value shown when an extension setting has no value"
|
||||
},
|
||||
"extensionActionFailed": "Action failed",
|
||||
"@extensionActionFailed": {
|
||||
"description": "Fallback error when an extension action fails without details"
|
||||
},
|
||||
"extensionEnterValue": "Enter value",
|
||||
"@extensionEnterValue": {
|
||||
"description": "Hint for editing an extension setting value"
|
||||
},
|
||||
"extensionHealthServiceOnline": "Service online",
|
||||
"@extensionHealthServiceOnline": {
|
||||
"description": "Tooltip for online extension service"
|
||||
},
|
||||
"extensionHealthServiceDegraded": "Service degraded",
|
||||
"@extensionHealthServiceDegraded": {
|
||||
"description": "Tooltip for degraded extension service"
|
||||
},
|
||||
"extensionHealthServiceOffline": "Service offline",
|
||||
"@extensionHealthServiceOffline": {
|
||||
"description": "Tooltip for offline extension service"
|
||||
},
|
||||
"extensionHealthServiceUnknown": "Service status unknown",
|
||||
"@extensionHealthServiceUnknown": {
|
||||
"description": "Tooltip for unknown extension service health"
|
||||
},
|
||||
"audioAnalysisStereo": "Stereo",
|
||||
"@audioAnalysisStereo": {
|
||||
"description": "Audio channel layout label - stereo"
|
||||
},
|
||||
"audioAnalysisMono": "Mono",
|
||||
"@audioAnalysisMono": {
|
||||
"description": "Audio channel layout label - mono"
|
||||
},
|
||||
"trackOpenInService": "Open in {serviceName}",
|
||||
"@trackOpenInService": {
|
||||
"description": "Button label to open a track in a named music service",
|
||||
"placeholders": {
|
||||
"serviceName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trackLyricsEmbeddedSource": "Embedded",
|
||||
"@trackLyricsEmbeddedSource": {
|
||||
"description": "Lyrics source label for embedded lyrics"
|
||||
},
|
||||
"unknownAlbum": "Unknown Album",
|
||||
"@unknownAlbum": {
|
||||
"description": "Fallback album name when metadata is missing"
|
||||
},
|
||||
"unknownArtist": "Unknown Artist",
|
||||
"@unknownArtist": {
|
||||
"description": "Fallback artist name when metadata is missing"
|
||||
},
|
||||
"permissionAudio": "Audio",
|
||||
"@permissionAudio": {
|
||||
"description": "Audio permission type label"
|
||||
},
|
||||
"permissionStorage": "Storage",
|
||||
"@permissionStorage": {
|
||||
"description": "Storage permission type label"
|
||||
},
|
||||
"permissionNotification": "Notification",
|
||||
"@permissionNotification": {
|
||||
"description": "Notification permission type label"
|
||||
},
|
||||
"errorInvalidFolderSelected": "Invalid folder selected",
|
||||
"@errorInvalidFolderSelected": {
|
||||
"description": "Error when the selected folder is invalid"
|
||||
},
|
||||
"errorCouldNotKeepFolderAccess": "Could not keep access to the selected folder",
|
||||
"@errorCouldNotKeepFolderAccess": {
|
||||
"description": "Error when persistent folder access cannot be saved"
|
||||
},
|
||||
"storeAnyVersion": "Any",
|
||||
"@storeAnyVersion": {
|
||||
"description": "Store detail value when any app version is accepted"
|
||||
},
|
||||
"storeCategoryMetadata": "Metadata",
|
||||
"@storeCategoryMetadata": {
|
||||
"description": "Store extension category - metadata"
|
||||
},
|
||||
"storeCategoryDownload": "Download",
|
||||
"@storeCategoryDownload": {
|
||||
"description": "Store extension category - download"
|
||||
},
|
||||
"storeCategoryUtility": "Utility",
|
||||
"@storeCategoryUtility": {
|
||||
"description": "Store extension category - utility"
|
||||
},
|
||||
"storeCategoryLyrics": "Lyrics",
|
||||
"@storeCategoryLyrics": {
|
||||
"description": "Store extension category - lyrics"
|
||||
},
|
||||
"storeCategoryIntegration": "Integration",
|
||||
"@storeCategoryIntegration": {
|
||||
"description": "Store extension category - integration"
|
||||
},
|
||||
"artistReleases": "Releases",
|
||||
"@artistReleases": {
|
||||
"description": "Section header for all artist releases"
|
||||
},
|
||||
"editMetadataSelectNone": "None",
|
||||
"@editMetadataSelectNone": {
|
||||
"description": "Button to clear selected fields for auto-fill"
|
||||
},
|
||||
"queueRetryAllFailed": "Retry {count} failed",
|
||||
"@queueRetryAllFailed": {
|
||||
"description": "Button to retry every failed download in the queue",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settingsSaveDownloadHistory": "Save download history",
|
||||
"@settingsSaveDownloadHistory": {
|
||||
"description": "Settings switch title for storing completed downloads in history"
|
||||
},
|
||||
"settingsSaveDownloadHistorySubtitle": "Keep completed downloads in history and library views",
|
||||
"@settingsSaveDownloadHistorySubtitle": {
|
||||
"description": "Settings switch subtitle for storing completed downloads in history"
|
||||
},
|
||||
"dialogDisableHistoryTitle": "Turn off download history?",
|
||||
"@dialogDisableHistoryTitle": {
|
||||
"description": "Confirmation dialog title shown before disabling download history"
|
||||
},
|
||||
"dialogDisableHistoryMessage": "Existing history will be cleared. Downloaded files will not be deleted.",
|
||||
"@dialogDisableHistoryMessage": {
|
||||
"description": "Confirmation dialog message shown before disabling download history"
|
||||
},
|
||||
"dialogDisableAndClear": "Turn off and clear",
|
||||
"@dialogDisableAndClear": {
|
||||
"description": "Confirmation action to disable download history and clear existing entries"
|
||||
},
|
||||
"openInOtherServices": "Open in Other Services",
|
||||
"@openInOtherServices": {
|
||||
"description": "Title and tooltip for finding the current collection in other services"
|
||||
},
|
||||
"shareSheetNoExtensions": "No other compatible services",
|
||||
"@shareSheetNoExtensions": {
|
||||
"description": "Empty state when no extensions can be searched for cross-service links"
|
||||
},
|
||||
"shareSheetNotFound": "Not found",
|
||||
"@shareSheetNotFound": {
|
||||
"description": "Cross-service share sheet row subtitle when a service has no match"
|
||||
},
|
||||
"shareSheetCopyLink": "Copy Link",
|
||||
"@shareSheetCopyLink": {
|
||||
"description": "Tooltip for copying a cross-service link"
|
||||
},
|
||||
"shareSheetLinkCopied": "{service} link copied",
|
||||
"@shareSheetLinkCopied": {
|
||||
"description": "Snackbar after copying a cross-service link",
|
||||
"placeholders": {
|
||||
"service": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,15 +369,15 @@
|
||||
"@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"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -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"
|
||||
},
|
||||
@@ -2071,43 +2050,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 +2676,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 +3579,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 +3817,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": {
|
||||
@@ -4196,9 +4175,17 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"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"
|
||||
|
||||
@@ -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,15 +369,15 @@
|
||||
"@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"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -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"
|
||||
},
|
||||
@@ -2071,43 +2050,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 +2676,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 +3579,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 +3817,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": {
|
||||
@@ -4196,9 +4175,17 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"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"
|
||||
|
||||
@@ -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,15 +369,15 @@
|
||||
"@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"
|
||||
},
|
||||
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.",
|
||||
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
|
||||
"@aboutAppDescription": {
|
||||
"description": "App description in header card"
|
||||
},
|
||||
@@ -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"
|
||||
},
|
||||
@@ -2071,43 +2050,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 +2676,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 +3579,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 +3817,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": {
|
||||
@@ -4196,9 +4175,17 @@
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
"audioAnalysisRescan": "Re-analyze",
|
||||
"@audioAnalysisRescan": {
|
||||
"description": "Tooltip/label for the button that re-runs the audio analysis, discarding cached results"
|
||||
},
|
||||
"audioAnalysisRescanning": "Re-analyzing audio...",
|
||||
"@audioAnalysisRescanning": {
|
||||
"description": "Loading text while audio is being re-analyzed after an explicit refresh"
|
||||
},
|
||||
"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"
|
||||
|
||||