Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86b8709ea1 | |||
| 702b917929 | |||
| 4b219ad18e | |||
| 57051bd649 | |||
| d6fca6ca55 | |||
| 153ec2d9e5 | |||
| be90e85d94 | |||
| 4af089f56c | |||
| 62519d2d1c | |||
| 27c0880e87 | |||
| f312b74b30 | |||
| bd49e307ef | |||
| e904a836c1 | |||
| 763c9478f1 | |||
| 427bdf74dc | |||
| 373a276c54 | |||
| dccadf1f87 | |||
| d9933fe038 | |||
| d47ac0934d | |||
| dbba4d6630 | |||
| 7405855e01 | |||
| ed020c9303 | |||
| 378742e37a | |||
| c79bee534e | |||
| 1d6df75829 | |||
| b7f51b5f14 | |||
| 1c8e9df727 | |||
| 01540fe3fc | |||
| 071db2f109 | |||
| e097d3f605 | |||
| 277f783f62 | |||
| 7637aaf168 | |||
| c4878470bf | |||
| a3725e8c48 | |||
| 917ba842f5 | |||
| dac17ead33 | |||
| 6845ebe04c | |||
| eff709480d | |||
| 67833424cc | |||
| 5c48e1b476 | |||
| 5e17c9f238 | |||
| 7d330fb2ec | |||
| cd6a4594fa | |||
| bcf727f4ec | |||
| 4c4553913f | |||
| f0013fac16 | |||
| ce4be0ba97 | |||
| 4bac38ef2a | |||
| 4b213f47d9 | |||
| a1010f72f2 | |||
| 21077a26d0 | |||
| b50eec5a47 | |||
| 38a8b715f8 | |||
| 2b47537bb5 | |||
| a5cf241846 | |||
| 53a4773480 | |||
| 89603af1f1 | |||
| 2143084d3c | |||
| 0e265193b8 | |||
| c7e9749ce4 | |||
| e21cffff0b | |||
| d9e20040be | |||
| 6689173525 | |||
| f37e4704a6 | |||
| 65dbd5c8e4 | |||
| d034144e9c | |||
| 7c4309955e | |||
| 63e90d13d4 | |||
| bfb0cad603 | |||
| cc10a917dc | |||
| 5e833c1f75 | |||
| 8c576ac7e4 | |||
| 92160537c0 | |||
| 120ecaa0e5 | |||
| fd3a34303e | |||
| d89b70e155 | |||
| e3b63c1d27 | |||
| 96301c0dbf | |||
| a2458c1292 | |||
| 1737e12dd2 | |||
| b770d7d9ca | |||
| b712b9f509 | |||
| 51496cd34e | |||
| 2b2c2bc90a | |||
| e2a489ec92 | |||
| 4f46dd947d | |||
| fbb8d30db0 | |||
| c0637006af | |||
| 3fc371b8c4 | |||
| ee5b3824e9 | |||
| be6a856773 | |||
| e41c299d49 | |||
| 981786b4a2 | |||
| eeb6f11808 | |||
| 8e361e14b4 | |||
| d58d46eb1f | |||
| 562a17f7ae | |||
| b035e66540 | |||
| 38792a753e | |||
| d5b34b4f15 | |||
| 2a45c8dcdb | |||
| e7a2166a4f | |||
| f54597e655 |
@@ -164,13 +164,18 @@ jobs:
|
||||
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
|
||||
|
||||
build-ios:
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-15
|
||||
needs: get-version # Only depends on version, NOT android build!
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Select Xcode 26.1.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.1.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
|
||||
@@ -168,7 +168,7 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md)
|
||||
|---|---|---|---|---|
|
||||
| [hifi-api](https://github.com/binimum/hifi-api) | [music.binimum.org](https://music.binimum.org) | [qqdl.site](https://qqdl.site) | [squid.wtf](https://squid.wtf) | [spotisaver.net](https://spotisaver.net) |
|
||||
| [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) |
|
||||
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | |
|
||||
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | [Monochrome](https://monochrome.tf) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ android {
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
@@ -57,6 +61,18 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
|
||||
getByName("profile") {
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
|
||||
release {
|
||||
// For local builds: use release signing if key.properties exists
|
||||
// For CI builds: APK is signed by GitHub Action after build
|
||||
@@ -71,6 +87,9 @@ android {
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,20 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="music.youtube.com" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Extension OAuth (PKCE) redirect: spotiflac://callback?code=...&state=<extension_id> -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="callback" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="spotiflac" android:host="spotify-callback" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Download Service -->
|
||||
@@ -94,24 +108,6 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<!-- Audio playback service for media notification / background audio -->
|
||||
<service
|
||||
android:name="com.ryanheise.audioservice.AudioService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- flutter_local_notifications receivers -->
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
|
||||
<receiver android:exported="false" android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
@@ -307,8 +308,40 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun forceFilenameExt(name: String, outputExt: String): String {
|
||||
val normalizedExt = normalizeExt(outputExt)
|
||||
if (normalizedExt.isBlank()) return sanitizeFilename(name)
|
||||
|
||||
val safeName = sanitizeFilename(name)
|
||||
val lower = safeName.lowercase(Locale.ROOT)
|
||||
val knownExts = listOf(".flac", ".m4a", ".mp3", ".opus", ".lrc")
|
||||
for (knownExt in knownExts) {
|
||||
if (lower.endsWith(knownExt)) {
|
||||
return safeName.dropLast(knownExt.length) + normalizedExt
|
||||
}
|
||||
}
|
||||
return safeName + normalizedExt
|
||||
}
|
||||
|
||||
private fun sanitizeFilename(name: String): String {
|
||||
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim()
|
||||
var sanitized = name
|
||||
.replace("/", " ")
|
||||
.replace(Regex("[\\\\:*?\"<>|]"), " ")
|
||||
.filter { ch ->
|
||||
val code = ch.code
|
||||
!((code < 0x20 && ch != '\t' && ch != '\n' && ch != '\r') ||
|
||||
code == 0x7F ||
|
||||
(Character.isISOControl(ch) && ch != '\t' && ch != '\n' && ch != '\r'))
|
||||
}
|
||||
.trim()
|
||||
.trim('.', ' ')
|
||||
|
||||
sanitized = sanitized
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.replace(Regex("_+"), "_")
|
||||
.trim('_', ' ')
|
||||
|
||||
return if (sanitized.isBlank()) "Unknown" else sanitized
|
||||
}
|
||||
|
||||
private fun sanitizeRelativeDir(relativeDir: String): String {
|
||||
@@ -368,6 +401,43 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return current
|
||||
}
|
||||
|
||||
private fun createOrReuseDocumentFile(
|
||||
parent: DocumentFile,
|
||||
mimeType: String,
|
||||
fileName: String
|
||||
): DocumentFile? {
|
||||
val safeFileName = sanitizeFilename(fileName)
|
||||
if (safeFileName.isBlank()) return null
|
||||
|
||||
synchronized(safDirLock) {
|
||||
val existing = parent.findFile(safeFileName)
|
||||
if (existing != null && existing.isFile) {
|
||||
return existing
|
||||
}
|
||||
|
||||
val created = parent.createFile(mimeType, safeFileName) ?: return null
|
||||
val createdName = created.name ?: safeFileName
|
||||
if (createdName == safeFileName) {
|
||||
return created
|
||||
}
|
||||
|
||||
// SAF can auto-rename to "name (1)" when another writer wins the race
|
||||
// between findFile() and createFile(). Prefer the exact sibling if it
|
||||
// appeared, and discard the duplicate document we just created.
|
||||
val winner = parent.findFile(safeFileName)
|
||||
if (winner != null && winner.isFile) {
|
||||
if (winner.uri != created.uri) {
|
||||
try {
|
||||
created.delete()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
return winner
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetSafScanProgress() {
|
||||
synchronized(safScanLock) {
|
||||
safScanProgress = SafScanProgress()
|
||||
@@ -599,12 +669,12 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
|
||||
val provided = req.optString("saf_file_name", "")
|
||||
if (provided.isNotBlank()) return sanitizeFilename(provided)
|
||||
if (provided.isNotBlank()) return forceFilenameExt(provided, outputExt)
|
||||
|
||||
val trackName = req.optString("track_name", "track")
|
||||
val artistName = req.optString("artist_name", "")
|
||||
val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName
|
||||
return sanitizeFilename(baseName) + outputExt
|
||||
return forceFilenameExt(baseName, outputExt)
|
||||
}
|
||||
|
||||
private fun errorJson(message: String): String {
|
||||
@@ -918,8 +988,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val targetDir = ensureDocumentDir(treeUri, relativeDir)
|
||||
?: return errorJson("Failed to access SAF directory")
|
||||
|
||||
val existingFile = targetDir.findFile(fileName)
|
||||
val document = existingFile ?: targetDir.createFile(mimeType, fileName)
|
||||
var document = createOrReuseDocumentFile(targetDir, mimeType, fileName)
|
||||
?: return errorJson("Failed to create SAF file")
|
||||
|
||||
val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
|
||||
@@ -944,16 +1013,34 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
) {
|
||||
try {
|
||||
val srcFile = java.io.File(goFilePath)
|
||||
if (srcFile.exists() && srcFile.length() > 0) {
|
||||
contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
|
||||
srcFile.inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
srcFile.delete()
|
||||
if (!srcFile.exists() || srcFile.length() <= 0) {
|
||||
throw IllegalStateException("extension output missing or empty: $goFilePath")
|
||||
}
|
||||
val actualExt = normalizeExt(srcFile.extension)
|
||||
if (actualExt.isNotBlank() && actualExt != outputExt) {
|
||||
val actualFileName = buildSafFileName(req, actualExt)
|
||||
val actualMimeType = mimeTypeForExt(actualExt)
|
||||
val replacement = createOrReuseDocumentFile(
|
||||
targetDir,
|
||||
actualMimeType,
|
||||
actualFileName,
|
||||
)
|
||||
?: throw IllegalStateException("failed to create SAF output with actual extension")
|
||||
if (replacement.uri != document.uri) {
|
||||
document.delete()
|
||||
document = replacement
|
||||
}
|
||||
}
|
||||
contentResolver.openOutputStream(document.uri, "wt")?.use { output ->
|
||||
srcFile.inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: throw IllegalStateException("failed to open SAF output stream")
|
||||
srcFile.delete()
|
||||
} catch (e: Exception) {
|
||||
document.delete()
|
||||
android.util.Log.w("SpotiFLAC", "Failed to copy extension output to SAF: ${e.message}")
|
||||
return errorJson("Failed to copy extension output to SAF: ${e.message}")
|
||||
}
|
||||
}
|
||||
respObj.put("file_path", document.uri.toString())
|
||||
@@ -1931,9 +2018,54 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
// We handle these URLs ourselves via receive_sharing_intent + ShareIntentService.
|
||||
override fun shouldHandleDeeplinking(): Boolean = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
handleExtensionOAuthIntent(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleExtensionOAuthIntent(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver Spotify (or other) OAuth authorization code to the extension runtime
|
||||
* and run its token exchange (e.g. completeSpotifyLogin). State must be the extension id.
|
||||
*/
|
||||
private fun handleExtensionOAuthIntent(intent: Intent?) {
|
||||
val uri = intent?.data ?: return
|
||||
if (!uri.scheme.equals("spotiflac", ignoreCase = true)) {
|
||||
return
|
||||
}
|
||||
val host = (uri.host ?: "").lowercase(Locale.US)
|
||||
val path = (uri.path ?: "").lowercase(Locale.US)
|
||||
val isCallback =
|
||||
host == "callback" ||
|
||||
host == "spotify-callback" ||
|
||||
path.contains("callback")
|
||||
if (!isCallback) {
|
||||
return
|
||||
}
|
||||
val code = uri.getQueryParameter("code")?.trim().orEmpty()
|
||||
if (code.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val extId = uri.getQueryParameter("state")?.trim().orEmpty()
|
||||
if (extId.isEmpty()) {
|
||||
android.util.Log.w("SpotiFLAC", "Extension OAuth redirect missing state (extension id)")
|
||||
return
|
||||
}
|
||||
intent.data = null
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Gobackend.setExtensionAuthCodeByID(extId, code)
|
||||
val json = Gobackend.invokeExtensionActionJSON(extId, "completeSpotifyLogin")
|
||||
android.util.Log.i("SpotiFLAC", "Extension OAuth complete for $extId: $json")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("SpotiFLAC", "Extension OAuth failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
@@ -1949,6 +2081,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
Gobackend.setAppVersion(BuildConfig.VERSION_NAME)
|
||||
|
||||
// Always-enabled back callback to ensure back presses reach Flutter.
|
||||
// Nested tab navigators can incorrectly set frameworkHandlesBack(false),
|
||||
@@ -2133,7 +2266,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
result.error("saf_pending", "SAF picker already active", null)
|
||||
return@launch
|
||||
}
|
||||
pendingSafTreeResult = result
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
intent.addFlags(
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
@@ -2141,7 +2273,24 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
|
||||
)
|
||||
safTreeLauncher.launch(intent)
|
||||
val resolver = intent.resolveActivity(packageManager)
|
||||
if (resolver == null) {
|
||||
result.error("saf_unavailable", "No folder picker available on this device", null)
|
||||
return@launch
|
||||
}
|
||||
pendingSafTreeResult = result
|
||||
try {
|
||||
android.util.Log.i("SpotiFLAC", "Launching SAF picker via $resolver")
|
||||
safTreeLauncher.launch(intent)
|
||||
} catch (e: Exception) {
|
||||
pendingSafTreeResult = null
|
||||
android.util.Log.e("SpotiFLAC", "Failed to launch SAF picker: ${e.message}", e)
|
||||
result.error(
|
||||
"saf_launch_failed",
|
||||
e.message ?: "Failed to launch folder picker",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
"safExists" -> {
|
||||
val uriStr = call.argument<String>("uri") ?: ""
|
||||
@@ -2216,7 +2365,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
|
||||
val existing = dir.findFile(fileName)
|
||||
val createdNew = existing == null
|
||||
val doc = existing ?: dir.createFile(mimeType, fileName) ?: return@withContext null
|
||||
val doc = createOrReuseDocumentFile(dir, mimeType, fileName)
|
||||
?: return@withContext null
|
||||
if (!writeUriFromPath(doc.uri, srcPath)) {
|
||||
if (createdNew) {
|
||||
doc.delete()
|
||||
@@ -2714,16 +2864,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"searchDeezerAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
val artistLimit = call.argument<Int>("artist_limit") ?: 2
|
||||
val filter = call.argument<String>("filter") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.searchDeezerAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"searchTidalAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
@@ -2965,6 +3105,13 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"setDownloadFallbackExtensionIds" -> {
|
||||
val extensionIdsJson = call.argument<String>("extension_ids") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.setExtensionFallbackProviderIDsJSON(extensionIdsJson)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"setMetadataProviderPriority" -> {
|
||||
val priorityJson = call.argument<String>("priority") ?: "[]"
|
||||
withContext(Dispatchers.IO) {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"name": "SpotiFLAC",
|
||||
"bundleIdentifier": "com.zarzet.spotiflac",
|
||||
"developerName": "zarzet",
|
||||
"version": "3.9.0",
|
||||
"versionDate": "2026-03-25",
|
||||
"downloadURL": "https://github.com/zarzet/SpotiFLAC-Mobile/releases/download/v3.9.0/SpotiFLAC-v3.9.0-ios-unsigned.ipa",
|
||||
"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",
|
||||
"localizedDescription": "Mobile version of SpotiFLAC written in Flutter. Download Tracks in true FLAC from Tidal, Qobuz, & Amazon Music.",
|
||||
"iconURL": "https://raw.githubusercontent.com/zarzet/SpotiFLAC-Mobile/main/assets/images/logo.png",
|
||||
"size": 34477323
|
||||
"size": 34773644
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 71 KiB |
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -367,12 +366,9 @@ func APETagToAudioMetadata(tag *APETag) *AudioMetadata {
|
||||
case "DATE":
|
||||
metadata.Date = value
|
||||
case "TRACK", "TRACKNUMBER":
|
||||
// APE track format can be "3" or "3/12"
|
||||
trackNum, _ := strconv.Atoi(strings.Split(value, "/")[0])
|
||||
metadata.TrackNumber = trackNum
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||
case "DISC", "DISCNUMBER":
|
||||
discNum, _ := strconv.Atoi(strings.Split(value, "/")[0])
|
||||
metadata.DiscNumber = discNum
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||
case "ISRC":
|
||||
metadata.ISRC = value
|
||||
case "LYRICS", "UNSYNCEDLYRICS":
|
||||
@@ -425,10 +421,10 @@ func AudioMetadataToAPEItems(metadata *AudioMetadata) []APETagItem {
|
||||
addItem("Year", metadata.Year)
|
||||
}
|
||||
if metadata.TrackNumber > 0 {
|
||||
addItem("Track", strconv.Itoa(metadata.TrackNumber))
|
||||
addItem("Track", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
|
||||
}
|
||||
if metadata.DiscNumber > 0 {
|
||||
addItem("Disc", strconv.Itoa(metadata.DiscNumber))
|
||||
addItem("Disc", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
|
||||
}
|
||||
addItem("ISRC", metadata.ISRC)
|
||||
addItem("Lyrics", metadata.Lyrics)
|
||||
@@ -453,7 +449,7 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
||||
"artist": "ARTIST",
|
||||
"album": "ALBUM",
|
||||
"album_artist": "ALBUM ARTIST",
|
||||
"date": "YEAR",
|
||||
"date": "DATE",
|
||||
"genre": "GENRE",
|
||||
"track_number": "TRACK",
|
||||
"disc_number": "DISC",
|
||||
@@ -475,7 +471,7 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
||||
}
|
||||
}
|
||||
// Some fields have reader aliases that must also be cleared when the
|
||||
// canonical key is updated (e.g. "Year" writer ↔ DATE/YEAR reader,
|
||||
// canonical key is updated (e.g. DATE writer ↔ DATE/YEAR reader,
|
||||
// DISC ↔ DISCNUMBER, TRACK ↔ TRACKNUMBER, "ALBUM ARTIST" ↔ ALBUMARTIST,
|
||||
// LABEL ↔ PUBLISHER, LYRICS ↔ UNSYNCEDLYRICS).
|
||||
if _, present := fields["date"]; present {
|
||||
@@ -484,9 +480,15 @@ func apeKeysFromFields(fields map[string]string) map[string]struct{} {
|
||||
if _, present := fields["disc_number"]; present {
|
||||
result["DISCNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["disc_total"]; present {
|
||||
result["DISCNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["track_number"]; present {
|
||||
result["TRACKNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["track_total"]; present {
|
||||
result["TRACKNUMBER"] = struct{}{}
|
||||
}
|
||||
if _, present := fields["album_artist"]; present {
|
||||
result["ALBUMARTIST"] = struct{}{}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,9 @@ type AudioMetadata struct {
|
||||
Year string
|
||||
Date string
|
||||
TrackNumber int
|
||||
TotalTracks int
|
||||
DiscNumber int
|
||||
TotalDiscs int
|
||||
ISRC string
|
||||
Lyrics string
|
||||
Label string
|
||||
@@ -173,9 +175,9 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
|
||||
case "TCO":
|
||||
metadata.Genre = cleanGenre(value)
|
||||
case "TRK":
|
||||
metadata.TrackNumber = parseTrackNumber(value)
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||
case "TPA":
|
||||
metadata.DiscNumber = parseTrackNumber(value)
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||
case "TCM":
|
||||
metadata.Composer = value
|
||||
case "TPB":
|
||||
@@ -292,9 +294,9 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
|
||||
case "TCON":
|
||||
metadata.Genre = cleanGenre(value)
|
||||
case "TRCK":
|
||||
metadata.TrackNumber = parseTrackNumber(value)
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||
case "TPOS":
|
||||
metadata.DiscNumber = parseTrackNumber(value)
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||
case "TSRC":
|
||||
metadata.ISRC = value
|
||||
case "TCOM":
|
||||
@@ -580,14 +582,28 @@ func cleanGenre(genre string) string {
|
||||
}
|
||||
|
||||
func parseTrackNumber(s string) int {
|
||||
s = strings.TrimSpace(s)
|
||||
if idx := strings.Index(s, "/"); idx > 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
num, _ := strconv.Atoi(s)
|
||||
num, _ := parseIndexPair(s)
|
||||
return num
|
||||
}
|
||||
|
||||
func parseIndexPair(s string) (int, int) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
first := s
|
||||
second := ""
|
||||
if idx := strings.Index(s, "/"); idx > 0 {
|
||||
first = s[:idx]
|
||||
second = s[idx+1:]
|
||||
}
|
||||
|
||||
num, _ := strconv.Atoi(strings.TrimSpace(first))
|
||||
total, _ := strconv.Atoi(strings.TrimSpace(second))
|
||||
return num, total
|
||||
}
|
||||
|
||||
func removeUnsync(data []byte) []byte {
|
||||
if len(data) == 0 {
|
||||
return data
|
||||
@@ -1037,9 +1053,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
|
||||
case "GENRE":
|
||||
metadata.Genre = value
|
||||
case "TRACKNUMBER", "TRACK":
|
||||
metadata.TrackNumber = parseTrackNumber(value)
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(value)
|
||||
case "DISCNUMBER", "DISC":
|
||||
metadata.DiscNumber = parseTrackNumber(value)
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(value)
|
||||
case "ISRC":
|
||||
metadata.ISRC = value
|
||||
case "COMPOSER":
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
var ErrDownloadCancelled = errors.New("download cancelled")
|
||||
|
||||
type cancelEntry struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
canceled bool
|
||||
}
|
||||
@@ -27,8 +28,21 @@ func initDownloadCancel(itemID string) context.Context {
|
||||
cancelMu.Lock()
|
||||
defer cancelMu.Unlock()
|
||||
|
||||
if entry, ok := cancelMap[itemID]; ok {
|
||||
if entry.ctx == nil {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
entry.ctx = ctx
|
||||
entry.cancel = cancel
|
||||
if entry.canceled && entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
}
|
||||
return entry.ctx
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancelMap[itemID] = &cancelEntry{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
canceled: false,
|
||||
}
|
||||
|
||||
@@ -513,6 +513,11 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
||||
album = "Unknown Album"
|
||||
}
|
||||
|
||||
composer := track.Composer
|
||||
if composer == "" {
|
||||
composer = sheet.Composer
|
||||
}
|
||||
|
||||
var duration int
|
||||
if i+1 < len(sheet.Tracks) {
|
||||
nextStart := sheet.Tracks[i+1].StartTime
|
||||
@@ -539,12 +544,15 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
|
||||
ScannedAt: scanTime,
|
||||
ISRC: track.ISRC,
|
||||
TrackNumber: track.Number,
|
||||
TotalTracks: len(sheet.Tracks),
|
||||
DiscNumber: 1,
|
||||
TotalDiscs: 1,
|
||||
Duration: duration,
|
||||
ReleaseDate: sheet.Date,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Genre: sheet.Genre,
|
||||
Composer: composer,
|
||||
Format: "cue+" + strings.TrimPrefix(audioExt, "."),
|
||||
}
|
||||
|
||||
|
||||
@@ -630,6 +630,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
}
|
||||
|
||||
isrcMap := c.fetchISRCsParallel(ctx, allTracks)
|
||||
totalDiscs := 0
|
||||
for _, track := range allTracks {
|
||||
if track.DiskNumber > totalDiscs {
|
||||
totalDiscs = track.DiskNumber
|
||||
}
|
||||
}
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(allTracks))
|
||||
albumType := album.RecordType
|
||||
@@ -658,6 +664,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
||||
TrackNumber: trackNum,
|
||||
TotalTracks: album.NbTracks,
|
||||
DiscNumber: track.DiskNumber,
|
||||
TotalDiscs: totalDiscs,
|
||||
ExternalURL: track.Link,
|
||||
ISRC: isrc,
|
||||
AlbumID: fmt.Sprintf("deezer:%d", album.ID),
|
||||
|
||||
@@ -1,444 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const deezerMusicDLURL = "https://api.zarz.moe/v1/dzr"
|
||||
|
||||
type DeezerDownloadResult struct {
|
||||
FilePath string
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
LyricsLRC string
|
||||
}
|
||||
|
||||
func isLikelySpotifyTrackID(value string) bool {
|
||||
if len(value) != 22 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= '0' && r <= '9':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
|
||||
deezerID := strings.TrimSpace(req.DeezerID)
|
||||
if deezerID == "" {
|
||||
if prefixed, found := strings.CutPrefix(strings.TrimSpace(req.SpotifyID), "deezer:"); found {
|
||||
deezerID = strings.TrimSpace(prefixed)
|
||||
}
|
||||
}
|
||||
if deezerID != "" {
|
||||
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
|
||||
if err := verifyDeezerTrack(req, deezerID, false); err != nil {
|
||||
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
|
||||
// Don't reject direct IDs from request payload — they're presumably correct.
|
||||
}
|
||||
return trackURL, nil
|
||||
}
|
||||
|
||||
spotifyID := strings.TrimSpace(req.SpotifyID)
|
||||
if spotifyID != "" && isLikelySpotifyTrackID(spotifyID) {
|
||||
songlink := NewSongLinkClient()
|
||||
availability, err := songlink.CheckTrackAvailability(spotifyID, "")
|
||||
if err == nil && availability.Deezer && availability.DeezerURL != "" {
|
||||
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
|
||||
if resolvedID != "" {
|
||||
if verifyErr := verifyDeezerTrack(req, resolvedID, true); verifyErr != nil {
|
||||
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||
// Fall through to ISRC search instead of using wrong track.
|
||||
} else {
|
||||
return availability.DeezerURL, nil
|
||||
}
|
||||
} else {
|
||||
return availability.DeezerURL, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isrc := strings.TrimSpace(req.ISRC)
|
||||
if isrc != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||
defer cancel()
|
||||
track, err := GetDeezerClient().SearchByISRC(ctx, isrc)
|
||||
if err == nil && track != nil {
|
||||
resolvedID := songLinkExtractDeezerTrackID(track)
|
||||
if resolvedID != "" {
|
||||
if verifyErr := verifyDeezerTrack(req, resolvedID, false); verifyErr != nil {
|
||||
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
|
||||
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
|
||||
}
|
||||
return fmt.Sprintf("https://www.deezer.com/track/%s", resolvedID), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not resolve Deezer track URL")
|
||||
}
|
||||
|
||||
func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
|
||||
defer cancel()
|
||||
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
|
||||
if err != nil {
|
||||
return nil // Can't verify — don't block the download.
|
||||
}
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: trackResp.Track.Name,
|
||||
ArtistName: trackResp.Track.Artists,
|
||||
ISRC: trackResp.Track.ISRC,
|
||||
Duration: trackResp.Track.DurationMS / 1000,
|
||||
SkipNameVerification: skipNameVerification,
|
||||
}
|
||||
if !trackMatchesRequest(req, resolved, "Deezer") {
|
||||
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
|
||||
req.ArtistName, req.TrackName, resolved.ArtistName, resolved.Title)
|
||||
}
|
||||
GoLog("[Deezer] Track %s verified: '%s - %s' ✓\n", deezerID, resolved.ArtistName, resolved.Title)
|
||||
return nil
|
||||
}
|
||||
|
||||
type deezerMusicDLRequest struct {
|
||||
Platform string `json:"platform"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, error) {
|
||||
payload := deezerMusicDLRequest{
|
||||
Platform: "deezer",
|
||||
URL: deezerTrackURL,
|
||||
}
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode MusicDL request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, deezerMusicDLURL, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create MusicDL request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("MusicDL request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read MusicDL response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("MusicDL returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return "", fmt.Errorf("invalid MusicDL JSON: %w", err)
|
||||
}
|
||||
|
||||
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
|
||||
return "", fmt.Errorf("MusicDL error: %s", errMsg)
|
||||
}
|
||||
|
||||
for _, key := range []string{"download_url", "url", "link"} {
|
||||
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
}
|
||||
}
|
||||
if data, ok := raw["data"].(map[string]any); ok {
|
||||
for _, key := range []string{"download_url", "url", "link"} {
|
||||
if urlVal, ok := data[key].(string); ok && strings.TrimSpace(urlVal) != "" {
|
||||
return strings.TrimSpace(urlVal), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no download URL found in MusicDL response")
|
||||
}
|
||||
|
||||
func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, outputFD int, itemID string) error {
|
||||
GoLog("[Deezer] Resolving download URL via MusicDL for: %s\n", deezerTrackURL)
|
||||
|
||||
downloadURL, err := c.GetMusicDLDownloadURL(deezerTrackURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
GoLog("[Deezer] MusicDL returned download URL, starting download...\n")
|
||||
|
||||
ctx := context.Background()
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create download request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
|
||||
resp, err := GetDownloadClient().Do(req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
expectedSize := resp.ContentLength
|
||||
if expectedSize > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
var written int64
|
||||
if itemID != "" {
|
||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||
written, err = io.Copy(pw, resp.Body)
|
||||
} else {
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush output: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close output: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Downloaded via MusicDL: %.2f MB\n", float64(written)/(1024*1024))
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
|
||||
deezerClient := GetDeezerClient()
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
|
||||
if !isSafOutput {
|
||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||
return DeezerDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||
}
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
|
||||
var outputPath string
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
} else {
|
||||
filename = sanitizeFilename(filename) + ".flac"
|
||||
outputPath = filepath.Join(req.OutputDir, filename)
|
||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||
return DeezerDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||
}
|
||||
}
|
||||
|
||||
var parallelResult *ParallelDownloadResult
|
||||
parallelDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(parallelDone)
|
||||
coverURL := req.CoverURL
|
||||
embedLyrics := req.EmbedLyrics
|
||||
if !req.EmbedMetadata {
|
||||
coverURL = ""
|
||||
embedLyrics = false
|
||||
}
|
||||
parallelResult = FetchCoverAndLyricsParallel(
|
||||
coverURL,
|
||||
req.EmbedMaxQualityCover,
|
||||
req.SpotifyID,
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
embedLyrics,
|
||||
int64(req.DurationMS),
|
||||
)
|
||||
}()
|
||||
|
||||
deezerTrackURL, deezerURLErr := resolveDeezerTrackURL(req)
|
||||
if deezerURLErr != nil {
|
||||
return DeezerDownloadResult{}, fmt.Errorf(
|
||||
"deezer download failed: could not resolve Deezer URL: %w",
|
||||
deezerURLErr,
|
||||
)
|
||||
}
|
||||
|
||||
GoLog("[Deezer] Trying MusicDL for: %s\n", deezerTrackURL)
|
||||
downloadErr := deezerClient.DownloadFromMusicDL(
|
||||
deezerTrackURL,
|
||||
outputPath,
|
||||
req.OutputFD,
|
||||
req.ItemID,
|
||||
)
|
||||
if downloadErr != nil {
|
||||
if errors.Is(downloadErr, ErrDownloadCancelled) {
|
||||
return DeezerDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
return DeezerDownloadResult{}, fmt.Errorf(
|
||||
"deezer download failed via MusicDL: %w",
|
||||
downloadErr,
|
||||
)
|
||||
}
|
||||
|
||||
<-parallelDone
|
||||
|
||||
if req.ItemID != "" {
|
||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||
SetItemFinalizing(req.ItemID)
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
AlbumArtist: req.AlbumArtist,
|
||||
ArtistTagMode: req.ArtistTagMode,
|
||||
Date: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
}
|
||||
|
||||
if isSafOutput || !req.EmbedMetadata {
|
||||
if !req.EmbedMetadata {
|
||||
GoLog("[Deezer] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n")
|
||||
} else {
|
||||
GoLog("[Deezer] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
|
||||
}
|
||||
} else {
|
||||
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
|
||||
GoLog("[Deezer] Warning: failed to embed metadata: %v\n", err)
|
||||
}
|
||||
|
||||
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsMode := req.LyricsMode
|
||||
if lyricsMode == "" {
|
||||
lyricsMode = "embed"
|
||||
}
|
||||
|
||||
if lyricsMode == "external" || lyricsMode == "both" {
|
||||
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
|
||||
GoLog("[Deezer] Warning: failed to save LRC file: %v\n", lrcErr)
|
||||
} else {
|
||||
GoLog("[Deezer] LRC file saved: %s\n", lrcPath)
|
||||
}
|
||||
}
|
||||
|
||||
if lyricsMode == "embed" || lyricsMode == "both" {
|
||||
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
|
||||
GoLog("[Deezer] Warning: failed to embed lyrics: %v\n", embedErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isSafOutput {
|
||||
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
|
||||
}
|
||||
|
||||
bitDepth, sampleRate := 0, 0
|
||||
if quality, qErr := GetAudioQuality(outputPath); qErr == nil {
|
||||
bitDepth = quality.BitDepth
|
||||
sampleRate = quality.SampleRate
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
}
|
||||
|
||||
return DeezerDownloadResult{
|
||||
FilePath: outputPath,
|
||||
BitDepth: bitDepth,
|
||||
SampleRate: sampleRate,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,6 +1,24 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetExtensionFallbackProviderIDsJSONEmptyStringResetsDefault(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
defer SetExtensionFallbackProviderIDs(original)
|
||||
|
||||
SetExtensionFallbackProviderIDs([]string{"custom-ext"})
|
||||
|
||||
if err := SetExtensionFallbackProviderIDsJSON(""); err != nil {
|
||||
t.Fatalf("SetExtensionFallbackProviderIDsJSON returned error: %v", err)
|
||||
}
|
||||
|
||||
if got := GetExtensionFallbackProviderIDs(); got != nil {
|
||||
t.Fatalf("expected nil fallback provider list after reset, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDownloadSuccessResponsePrefersRequestedAlbumMetadata(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
@@ -114,6 +132,124 @@ func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDownloadSuccessResponseNormalizesDecryptionDescriptor(t *testing.T) {
|
||||
req := DownloadRequest{
|
||||
TrackName: "Track",
|
||||
ArtistName: "Artist",
|
||||
}
|
||||
|
||||
result := DownloadResult{
|
||||
Title: "Track",
|
||||
Artist: "Artist",
|
||||
DecryptionKey: "00112233",
|
||||
}
|
||||
|
||||
resp := buildDownloadSuccessResponse(
|
||||
req,
|
||||
result,
|
||||
"amazon",
|
||||
"ok",
|
||||
"/tmp/test.m4a",
|
||||
false,
|
||||
)
|
||||
|
||||
if resp.Decryption == nil {
|
||||
t.Fatal("expected decryption descriptor to be present")
|
||||
}
|
||||
if resp.Decryption.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||
t.Fatalf("strategy = %q", resp.Decryption.Strategy)
|
||||
}
|
||||
if resp.Decryption.Key != result.DecryptionKey {
|
||||
t.Fatalf("key = %q, want %q", resp.Decryption.Key, result.DecryptionKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMusicBrainzGenrePrefersHighestCountTag(t *testing.T) {
|
||||
got := formatMusicBrainzGenre([]musicBrainzTag{
|
||||
{Name: "art pop", Count: 3},
|
||||
{Name: "pop", Count: 8},
|
||||
{Name: "dance pop", Count: 5},
|
||||
})
|
||||
|
||||
if got != "Pop" {
|
||||
t.Fatalf("genre = %q, want %q", got, "Pop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichExtraMetadataByISRCFallsBackToMusicBrainzGenre(t *testing.T) {
|
||||
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
|
||||
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
|
||||
defer func() {
|
||||
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
|
||||
fetchMusicBrainzGenreByISRC = origMusicBrainzFetcher
|
||||
}()
|
||||
|
||||
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||
return nil, nil
|
||||
}
|
||||
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
|
||||
if isrc != "TEST123" {
|
||||
t.Fatalf("unexpected isrc: %q", isrc)
|
||||
}
|
||||
return "Alternative Rock", nil
|
||||
}
|
||||
|
||||
genre := ""
|
||||
label := ""
|
||||
copyright := ""
|
||||
enrichExtraMetadataByISRC("DownloadWithFallback", "TEST123", &genre, &label, ©right)
|
||||
|
||||
if genre != "Alternative Rock" {
|
||||
t.Fatalf("genre = %q, want fallback genre", genre)
|
||||
}
|
||||
if label != "" {
|
||||
t.Fatalf("label = %q, want empty", label)
|
||||
}
|
||||
if copyright != "" {
|
||||
t.Fatalf("copyright = %q, want empty", copyright)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichExtraMetadataByISRCPrefersDeezerGenre(t *testing.T) {
|
||||
origDeezerFetcher := fetchDeezerExtendedMetadataByISRC
|
||||
origMusicBrainzFetcher := fetchMusicBrainzGenreByISRC
|
||||
defer func() {
|
||||
fetchDeezerExtendedMetadataByISRC = origDeezerFetcher
|
||||
fetchMusicBrainzGenreByISRC = origMusicBrainzFetcher
|
||||
}()
|
||||
|
||||
musicBrainzCalled := false
|
||||
fetchDeezerExtendedMetadataByISRC = func(ctx context.Context, isrc string) (*AlbumExtendedMetadata, error) {
|
||||
return &AlbumExtendedMetadata{
|
||||
Genre: "Synthpop",
|
||||
Label: "EMI",
|
||||
Copyright: "(C) Test",
|
||||
}, nil
|
||||
}
|
||||
fetchMusicBrainzGenreByISRC = func(isrc string) (string, error) {
|
||||
musicBrainzCalled = true
|
||||
return "Rock", nil
|
||||
}
|
||||
|
||||
genre := ""
|
||||
label := ""
|
||||
copyright := ""
|
||||
enrichExtraMetadataByISRC("DownloadWithFallback", "TEST456", &genre, &label, ©right)
|
||||
|
||||
if genre != "Synthpop" {
|
||||
t.Fatalf("genre = %q, want Deezer genre", genre)
|
||||
}
|
||||
if label != "EMI" {
|
||||
t.Fatalf("label = %q, want Deezer label", label)
|
||||
}
|
||||
if copyright != "(C) Test" {
|
||||
t.Fatalf("copyright = %q, want Deezer copyright", copyright)
|
||||
}
|
||||
if musicBrainzCalled {
|
||||
t.Fatal("expected MusicBrainz not to be called when Deezer already provides genre")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
SpotifyID: "spotify-track-id",
|
||||
@@ -195,13 +331,11 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
||||
|
||||
metadata := buildReEnrichFFmpegMetadata(&req, "")
|
||||
|
||||
// Title and Artist are never written by re-enrich (they are search keys
|
||||
// preserved as-is from the file).
|
||||
if _, exists := metadata["TITLE"]; exists {
|
||||
t.Fatalf("TITLE should not be in metadata: %#v", metadata)
|
||||
if metadata["TITLE"] != "Song" {
|
||||
t.Fatalf("title = %q", metadata["TITLE"])
|
||||
}
|
||||
if _, exists := metadata["ARTIST"]; exists {
|
||||
t.Fatalf("ARTIST should not be in metadata: %#v", metadata)
|
||||
if metadata["ARTIST"] != "Artist" {
|
||||
t.Fatalf("artist = %q", metadata["ARTIST"])
|
||||
}
|
||||
if metadata["ALBUM"] != "Album" {
|
||||
t.Fatalf("album = %q", metadata["ALBUM"])
|
||||
@@ -224,3 +358,75 @@ func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReEnrichSearchQuerySkipsPlaceholderArtist(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Sign of the Times",
|
||||
ArtistName: "Unknown Artist",
|
||||
AlbumName: "Harry Styles",
|
||||
}
|
||||
|
||||
query := buildReEnrichSearchQuery(req)
|
||||
if query != "Sign of the Times" {
|
||||
t.Fatalf("query = %q", query)
|
||||
}
|
||||
|
||||
req = reEnrichRequest{
|
||||
TrackName: "Unknown Title",
|
||||
ArtistName: "Unknown Artist",
|
||||
AlbumName: "Harry Styles",
|
||||
}
|
||||
query = buildReEnrichSearchQuery(req)
|
||||
if query != "Harry Styles" {
|
||||
t.Fatalf("fallback album query = %q", query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyReEnrichTrackMetadataCopiesComposerAndTotals(t *testing.T) {
|
||||
req := reEnrichRequest{}
|
||||
|
||||
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
|
||||
Name: "Resolved Song",
|
||||
Artists: "Resolved Artist",
|
||||
TrackNumber: 7,
|
||||
TotalTracks: 12,
|
||||
DiscNumber: 2,
|
||||
TotalDiscs: 3,
|
||||
Composer: "Composer",
|
||||
})
|
||||
|
||||
if req.TrackNumber != 7 || req.TotalTracks != 12 {
|
||||
t.Fatalf("track metadata = %d/%d", req.TrackNumber, req.TotalTracks)
|
||||
}
|
||||
if req.DiscNumber != 2 || req.TotalDiscs != 3 {
|
||||
t.Fatalf("disc metadata = %d/%d", req.DiscNumber, req.TotalDiscs)
|
||||
}
|
||||
if req.TrackName != "Resolved Song" || req.ArtistName != "Resolved Artist" {
|
||||
t.Fatalf("basic tags = %q / %q", req.TrackName, req.ArtistName)
|
||||
}
|
||||
if req.Composer != "Composer" {
|
||||
t.Fatalf("composer = %q", req.Composer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReEnrichFFmpegMetadataFormatsTotalsAndComposer(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackNumber: 7,
|
||||
TotalTracks: 12,
|
||||
DiscNumber: 2,
|
||||
TotalDiscs: 3,
|
||||
Composer: "Composer",
|
||||
}
|
||||
|
||||
metadata := buildReEnrichFFmpegMetadata(&req, "")
|
||||
|
||||
if metadata["TRACKNUMBER"] != "7/12" {
|
||||
t.Fatalf("TRACKNUMBER = %q", metadata["TRACKNUMBER"])
|
||||
}
|
||||
if metadata["DISCNUMBER"] != "2/3" {
|
||||
t.Fatalf("DISCNUMBER = %q", metadata["DISCNUMBER"])
|
||||
}
|
||||
if metadata["COMPOSER"] != "Composer" {
|
||||
t.Fatalf("COMPOSER = %q", metadata["COMPOSER"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,12 +43,12 @@ func compareVersions(v1, v2 string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
type LoadedExtension struct {
|
||||
type loadedExtension struct {
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
runtime *ExtensionRuntime
|
||||
runtime *extensionRuntime
|
||||
initialized bool
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
@@ -73,7 +73,7 @@ func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
||||
return filtered
|
||||
}
|
||||
|
||||
func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error {
|
||||
func ensureRuntimeReadyLocked(ext *loadedExtension, applyStoredSettings bool) error {
|
||||
if ext.VM == nil || ext.runtime == nil {
|
||||
if err := initializeVMLocked(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
@@ -100,14 +100,14 @@ func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ext *LoadedExtension) ensureRuntimeReady() error {
|
||||
func (ext *loadedExtension) ensureRuntimeReady() error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
return ensureRuntimeReadyLocked(ext, true)
|
||||
}
|
||||
|
||||
func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||
func (ext *loadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||
ext.VMMu.Lock()
|
||||
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
|
||||
ext.VMMu.Unlock()
|
||||
@@ -116,28 +116,28 @@ func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||
return ext.VM, nil
|
||||
}
|
||||
|
||||
type ExtensionManager struct {
|
||||
type extensionManager struct {
|
||||
mu sync.RWMutex
|
||||
extensions map[string]*LoadedExtension
|
||||
extensions map[string]*loadedExtension
|
||||
extensionsDir string
|
||||
dataDir string
|
||||
}
|
||||
|
||||
var (
|
||||
globalExtManager *ExtensionManager
|
||||
globalExtManager *extensionManager
|
||||
globalExtManagerOnce sync.Once
|
||||
)
|
||||
|
||||
func GetExtensionManager() *ExtensionManager {
|
||||
func getExtensionManager() *extensionManager {
|
||||
globalExtManagerOnce.Do(func() {
|
||||
globalExtManager = &ExtensionManager{
|
||||
extensions: make(map[string]*LoadedExtension),
|
||||
globalExtManager = &extensionManager{
|
||||
extensions: make(map[string]*loadedExtension),
|
||||
}
|
||||
})
|
||||
return globalExtManager
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
func (m *extensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -154,7 +154,7 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtension, error) {
|
||||
func (m *extensionManager) LoadExtensionFromFile(filePath string) (*loadedExtension, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
@@ -272,7 +272,7 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||
}
|
||||
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: manifest.Name,
|
||||
Manifest: manifest,
|
||||
Enabled: false, // New extensions start disabled
|
||||
@@ -292,7 +292,7 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
func initializeVMLocked(ext *LoadedExtension) error {
|
||||
func initializeVMLocked(ext *loadedExtension) error {
|
||||
ext.VM = nil
|
||||
ext.runtime = nil
|
||||
ext.initialized = false
|
||||
@@ -305,7 +305,7 @@ func initializeVMLocked(ext *LoadedExtension) error {
|
||||
return fmt.Errorf("failed to read index.js: %w", err)
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
ext.runtime = runtime
|
||||
runtime.RegisterAPIs(vm)
|
||||
runtime.RegisterGoBackendAPIs(vm)
|
||||
@@ -342,14 +342,14 @@ func initializeVMLocked(ext *LoadedExtension) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
func (m *extensionManager) initializeVM(ext *loadedExtension) error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
return initializeVMLocked(ext)
|
||||
}
|
||||
|
||||
func initializeExtensionWithSettingsLocked(
|
||||
ext *LoadedExtension,
|
||||
ext *loadedExtension,
|
||||
settings map[string]interface{},
|
||||
) error {
|
||||
if ext.VM == nil {
|
||||
@@ -405,7 +405,7 @@ func initializeExtensionWithSettingsLocked(
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCleanupLocked(ext *LoadedExtension) error {
|
||||
func runCleanupLocked(ext *loadedExtension) error {
|
||||
if ext.VM != nil {
|
||||
script := `
|
||||
(function() {
|
||||
@@ -446,7 +446,7 @@ func runCleanupLocked(ext *LoadedExtension) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func teardownVMLocked(ext *LoadedExtension) {
|
||||
func teardownVMLocked(ext *loadedExtension) {
|
||||
if err := runCleanupLocked(ext); err != nil {
|
||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
||||
}
|
||||
@@ -461,7 +461,7 @@ func teardownVMLocked(ext *LoadedExtension) {
|
||||
ext.initialized = false
|
||||
}
|
||||
|
||||
func validateExtensionLoad(ext *LoadedExtension) error {
|
||||
func validateExtensionLoad(ext *loadedExtension) error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
@@ -472,7 +472,7 @@ func validateExtensionLoad(ext *LoadedExtension) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
func (m *extensionManager) UnloadExtension(extensionID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -491,7 +491,7 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, error) {
|
||||
func (m *extensionManager) GetExtension(extensionID string) (*loadedExtension, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
@@ -502,18 +502,18 @@ func (m *ExtensionManager) GetExtension(extensionID string) (*LoadedExtension, e
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetAllExtensions() []*LoadedExtension {
|
||||
func (m *extensionManager) GetAllExtensions() []*loadedExtension {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make([]*LoadedExtension, 0, len(m.extensions))
|
||||
result := make([]*loadedExtension, 0, len(m.extensions))
|
||||
for _, ext := range m.extensions {
|
||||
result = append(result, ext)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
||||
func (m *extensionManager) SetExtensionEnabled(extensionID string, enabled bool) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -547,7 +547,7 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
||||
func (m *extensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string, []error) {
|
||||
var loaded []string
|
||||
var errors []error
|
||||
|
||||
@@ -585,7 +585,7 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
||||
return loaded, errors
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedExtension, error) {
|
||||
func (m *extensionManager) loadExtensionFromDirectory(dirPath string) (*loadedExtension, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -615,7 +615,7 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||
}
|
||||
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: manifest.Name,
|
||||
Manifest: manifest,
|
||||
Enabled: false, // Will be restored from settings store
|
||||
@@ -643,7 +643,7 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
||||
func (m *extensionManager) RemoveExtension(extensionID string) error {
|
||||
ext, err := m.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -663,7 +663,7 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error {
|
||||
}
|
||||
|
||||
// Only allows upgrades (new version > current version), not downgrades
|
||||
func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) {
|
||||
func (m *extensionManager) UpgradeExtension(filePath string) (*loadedExtension, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
@@ -777,7 +777,7 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
}
|
||||
}
|
||||
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: newManifest.Name,
|
||||
Manifest: newManifest,
|
||||
Enabled: wasEnabled, // Preserve enabled state from before upgrade
|
||||
@@ -812,7 +812,7 @@ type ExtensionUpgradeInfo struct {
|
||||
IsInstalled bool `json:"is_installed"`
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||
func (m *extensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) {
|
||||
if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") {
|
||||
return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file")
|
||||
}
|
||||
@@ -871,7 +871,7 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
||||
func (m *extensionManager) CheckExtensionUpgradeJSON(filePath string) (string, error) {
|
||||
info, err := m.checkExtensionUpgradeInternal(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -885,7 +885,7 @@ func (m *ExtensionManager) CheckExtensionUpgradeJSON(filePath string) (string, e
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
func (m *extensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
extensions := m.GetAllExtensions()
|
||||
|
||||
type ExtensionInfo struct {
|
||||
@@ -893,7 +893,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
IconPath string `json:"icon_path,omitempty"`
|
||||
@@ -951,7 +950,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
Name: ext.Manifest.Name,
|
||||
DisplayName: ext.Manifest.DisplayName,
|
||||
Version: ext.Manifest.Version,
|
||||
Author: ext.Manifest.Author,
|
||||
Description: ext.Manifest.Description,
|
||||
Homepage: ext.Manifest.Homepage,
|
||||
IconPath: iconPath,
|
||||
@@ -982,7 +980,7 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
||||
func (m *extensionManager) InitializeExtension(extensionID string, settings map[string]interface{}) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -1000,7 +998,7 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
||||
return initializeExtensionWithSettingsLocked(ext, settings)
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
func (m *extensionManager) CleanupExtension(extensionID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -1022,7 +1020,7 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) UnloadAllExtensions() {
|
||||
func (m *extensionManager) UnloadAllExtensions() {
|
||||
m.mu.Lock()
|
||||
extensionIDs := make([]string, 0, len(m.extensions))
|
||||
for id := range m.extensions {
|
||||
@@ -1037,7 +1035,7 @@ func (m *ExtensionManager) UnloadAllExtensions() {
|
||||
GoLog("[Extension] All extensions unloaded\n")
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
||||
func (m *extensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -1055,15 +1053,29 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
||||
}
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
// Merge extension return values onto the top-level JSON object so Flutter can read
|
||||
// message, open_auth_url, setting_updates without unwrapping a nested "result" key.
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||
try {
|
||||
var result = extension.%s();
|
||||
if (result && typeof result.then === 'function') {
|
||||
// Handle promise - return pending status
|
||||
return { success: true, pending: true, message: 'Action started' };
|
||||
}
|
||||
if (result !== null && result !== undefined && typeof result === 'object') {
|
||||
var isArr = false;
|
||||
if (typeof Array !== 'undefined' && Array.isArray) {
|
||||
isArr = Array.isArray(result);
|
||||
}
|
||||
if (!isArr) {
|
||||
var out = { success: true };
|
||||
for (var k in result) {
|
||||
out[k] = result[k];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return { success: true, result: result };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
|
||||
@@ -105,7 +105,6 @@ type ExtensionManifest struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
@@ -155,10 +154,6 @@ func (m *ExtensionManifest) Validate() error {
|
||||
return &ManifestValidationError{Field: "version", Message: "version is required"}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.Author) == "" {
|
||||
return &ManifestValidationError{Field: "author", Message: "author is required"}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.Description) == "" {
|
||||
return &ManifestValidationError{Field: "description", Message: "description is required"}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
||||
original := GetMetadataProviderPriority()
|
||||
@@ -8,7 +12,7 @@ func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
||||
|
||||
SetMetadataProviderPriority([]string{"tidal"})
|
||||
got := GetMetadataProviderPriority()
|
||||
want := []string{"tidal", "deezer", "qobuz"}
|
||||
want := []string{"tidal", "qobuz"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected priority length: got %v want %v", got, want)
|
||||
}
|
||||
@@ -19,6 +23,190 @@ func TestSetMetadataProviderPriorityAddsBuiltIns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetExtensionFallbackProviderIDsSkipsBuiltInsAndDuplicates(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
defer SetExtensionFallbackProviderIDs(original)
|
||||
|
||||
SetExtensionFallbackProviderIDs([]string{"ext-a", "tidal", "ext-a", " ext-b "})
|
||||
|
||||
got := GetExtensionFallbackProviderIDs()
|
||||
want := []string{"ext-a", "ext-b"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("unexpected fallback provider length: got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("unexpected fallback provider at %d: got %v want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExtensionFallbackAllowedDefaultsToAllExtensions(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
defer SetExtensionFallbackProviderIDs(original)
|
||||
|
||||
SetExtensionFallbackProviderIDs(nil)
|
||||
|
||||
if !isExtensionFallbackAllowed("custom-ext") {
|
||||
t.Fatal("expected custom extension to be allowed when no fallback allowlist is configured")
|
||||
}
|
||||
if !isExtensionFallbackAllowed("qobuz") {
|
||||
t.Fatal("expected built-in provider to remain allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExtensionFallbackAllowedRespectsAllowlist(t *testing.T) {
|
||||
original := GetExtensionFallbackProviderIDs()
|
||||
defer SetExtensionFallbackProviderIDs(original)
|
||||
|
||||
SetExtensionFallbackProviderIDs([]string{"allowed-ext"})
|
||||
|
||||
if !isExtensionFallbackAllowed("allowed-ext") {
|
||||
t.Fatal("expected explicitly allowed extension to be permitted")
|
||||
}
|
||||
if isExtensionFallbackAllowed("blocked-ext") {
|
||||
t.Fatal("expected extension outside allowlist to be blocked")
|
||||
}
|
||||
if isExtensionFallbackAllowed("deezer") {
|
||||
t.Fatal("expected retired Deezer downloader to respect extension fallback allowlist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetProviderPriorityRemovesRetiredDeezerDownloader(t *testing.T) {
|
||||
original := GetProviderPriority()
|
||||
defer SetProviderPriority(original)
|
||||
|
||||
SetProviderPriority([]string{"deezer", "qobuz", "custom-ext"})
|
||||
|
||||
got := GetProviderPriority()
|
||||
want := []string{"qobuz", "custom-ext", "tidal"}
|
||||
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 TestNormalizeDownloadDecryptionInfoPromotesLegacyKey(t *testing.T) {
|
||||
normalized := normalizeDownloadDecryptionInfo(nil, " 001122 ")
|
||||
if normalized == nil {
|
||||
t.Fatal("expected legacy decryption key to produce normalized descriptor")
|
||||
}
|
||||
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||
t.Fatalf("strategy = %q", normalized.Strategy)
|
||||
}
|
||||
if normalized.Key != "001122" {
|
||||
t.Fatalf("key = %q", normalized.Key)
|
||||
}
|
||||
if normalized.InputFormat != "mov" {
|
||||
t.Fatalf("input format = %q", normalized.InputFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDownloadDecryptionInfoCanonicalizesMovAliases(t *testing.T) {
|
||||
normalized := normalizeDownloadDecryptionInfo(&DownloadDecryptionInfo{
|
||||
Strategy: "mp4_decryption_key",
|
||||
Key: "abcd",
|
||||
InputFormat: "",
|
||||
}, "")
|
||||
if normalized == nil {
|
||||
t.Fatal("expected descriptor to remain available")
|
||||
}
|
||||
if normalized.Strategy != genericFFmpegMOVDecryptionStrategy {
|
||||
t.Fatalf("strategy = %q", normalized.Strategy)
|
||||
}
|
||||
if normalized.InputFormat != "mov" {
|
||||
t.Fatalf("input format = %q", normalized.InputFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathAddsExplicitOutputDirToAllowedDirs(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
outputDir := t.TempDir()
|
||||
outputPath := buildOutputPath(DownloadRequest{
|
||||
TrackName: "Song",
|
||||
ArtistName: "Artist",
|
||||
OutputDir: outputDir,
|
||||
OutputExt: ".flac",
|
||||
FilenameFormat: "",
|
||||
})
|
||||
|
||||
if !isPathInAllowedDirs(outputPath) {
|
||||
t.Fatalf("expected output path %q to be allowed", outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathForExtensionAddsExplicitOutputPathDirToAllowedDirs(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
outputDir := t.TempDir()
|
||||
outputPath := filepath.Join(outputDir, "custom.flac")
|
||||
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||
|
||||
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||
OutputPath: outputPath,
|
||||
}, ext)
|
||||
|
||||
if resolved != outputPath {
|
||||
t.Fatalf("resolved output path = %q", resolved)
|
||||
}
|
||||
if !isPathInAllowedDirs(outputPath) {
|
||||
t.Fatalf("expected output path %q to be allowed", outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutputPathForExtensionUsesTempDirForFDOutput(t *testing.T) {
|
||||
SetAllowedDownloadDirs(nil)
|
||||
|
||||
ext := &loadedExtension{DataDir: t.TempDir()}
|
||||
resolved := buildOutputPathForExtension(DownloadRequest{
|
||||
TrackName: "Song",
|
||||
ArtistName: "Artist",
|
||||
OutputDir: filepath.Join("Artist", "Album"),
|
||||
OutputFD: 123,
|
||||
OutputExt: ".flac",
|
||||
}, ext)
|
||||
|
||||
expectedBase := filepath.Join(ext.DataDir, "downloads")
|
||||
if !isPathWithinBase(expectedBase, resolved) {
|
||||
t.Fatalf("expected SAF extension output under %q, got %q", expectedBase, resolved)
|
||||
}
|
||||
if !isPathInAllowedDirs(resolved) {
|
||||
t.Fatalf("expected resolved output path %q to be allowed", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanEmbedGenreLabelRequiresExistingAbsoluteLocalFile(t *testing.T) {
|
||||
tempFile := filepath.Join(t.TempDir(), "track.flac")
|
||||
if err := os.WriteFile(tempFile, []byte("fLaC"), 0644); err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
tempM4A := filepath.Join(t.TempDir(), "track.m4a")
|
||||
if err := os.WriteFile(tempM4A, []byte("not-flac"), 0644); err != nil {
|
||||
t.Fatalf("failed to create temp m4a file: %v", err)
|
||||
}
|
||||
|
||||
if canEmbedGenreLabel("relative.flac") {
|
||||
t.Fatal("expected relative path to be rejected")
|
||||
}
|
||||
if canEmbedGenreLabel("content://example") {
|
||||
t.Fatal("expected content URI to be rejected")
|
||||
}
|
||||
if canEmbedGenreLabel(filepath.Join(t.TempDir(), "missing.flac")) {
|
||||
t.Fatal("expected missing file to be rejected")
|
||||
}
|
||||
if canEmbedGenreLabel(tempM4A) {
|
||||
t.Fatalf("expected non-FLAC file %q to be rejected", tempM4A)
|
||||
}
|
||||
if !canEmbedGenreLabel(tempFile) {
|
||||
t.Fatalf("expected existing absolute file %q to be accepted", tempFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
||||
originalPriority := GetMetadataProviderPriority()
|
||||
originalSearch := searchBuiltInMetadataTracksFunc
|
||||
@@ -27,7 +215,7 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
||||
searchBuiltInMetadataTracksFunc = originalSearch
|
||||
}()
|
||||
|
||||
SetMetadataProviderPriority([]string{"qobuz", "tidal", "deezer"})
|
||||
SetMetadataProviderPriority([]string{"qobuz", "tidal"})
|
||||
|
||||
var calls []string
|
||||
searchBuiltInMetadataTracksFunc = func(providerID, query string, limit int) ([]ExtTrackMetadata, error) {
|
||||
@@ -42,27 +230,23 @@ func TestSearchTracksWithMetadataProvidersUsesPriorityAndDedupes(t *testing.T) {
|
||||
{ProviderID: "tidal", SpotifyID: "tidal:2", ISRC: "AAA111", Name: "Duplicate"},
|
||||
{ProviderID: "tidal", SpotifyID: "tidal:3", ISRC: "BBB222", Name: "Second"},
|
||||
}, nil
|
||||
case "deezer":
|
||||
return []ExtTrackMetadata{
|
||||
{ProviderID: "deezer", SpotifyID: "deezer:4", ISRC: "CCC333", Name: "Third"},
|
||||
}, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
tracks, err := manager.SearchTracksWithMetadataProviders("query", 3, false)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchTracksWithMetadataProviders returned error: %v", err)
|
||||
}
|
||||
if len(tracks) != 3 {
|
||||
t.Fatalf("unexpected track count: got %d want 3", len(tracks))
|
||||
if len(tracks) != 2 {
|
||||
t.Fatalf("unexpected track count: got %d want 2", len(tracks))
|
||||
}
|
||||
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" || tracks[2].ProviderID != "deezer" {
|
||||
if tracks[0].ProviderID != "qobuz" || tracks[1].ProviderID != "tidal" {
|
||||
t.Fatalf("unexpected track provider order: %+v", tracks)
|
||||
}
|
||||
if len(calls) != 3 || calls[0] != "qobuz" || calls[1] != "tidal" || calls[2] != "deezer" {
|
||||
if len(calls) != 2 || calls[0] != "qobuz" || calls[1] != "tidal" {
|
||||
t.Fatalf("unexpected provider call order: %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -80,7 +81,7 @@ func SetExtensionTokens(extensionID string, accessToken, refreshToken string, ex
|
||||
state.IsAuthenticated = accessToken != ""
|
||||
}
|
||||
|
||||
type ExtensionRuntime struct {
|
||||
type extensionRuntime struct {
|
||||
extensionID string
|
||||
manifest *ExtensionManifest
|
||||
settings map[string]interface{}
|
||||
@@ -123,10 +124,10 @@ var (
|
||||
privateIPCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
func newExtensionRuntime(ext *loadedExtension) *extensionRuntime {
|
||||
jar, _ := newSimpleCookieJar()
|
||||
|
||||
runtime := &ExtensionRuntime{
|
||||
runtime := &extensionRuntime{
|
||||
extensionID: ext.ID,
|
||||
manifest: ext.Manifest,
|
||||
settings: make(map[string]interface{}),
|
||||
@@ -136,31 +137,92 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||
storageFlushDelay: defaultStorageFlushDelay,
|
||||
}
|
||||
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, jar, 30*time.Second)
|
||||
runtime.httpClient = newExtensionHTTPClient(ext, jar, extensionHTTPTimeout(ext, 30*time.Second))
|
||||
runtime.downloadClient = newExtensionHTTPClient(ext, jar, DownloadTimeout)
|
||||
|
||||
return runtime
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) setActiveDownloadItemID(itemID string) {
|
||||
func extensionHTTPTimeout(ext *loadedExtension, fallback time.Duration) time.Duration {
|
||||
if ext == nil || ext.Manifest == nil || ext.Manifest.Capabilities == nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
raw, ok := ext.Manifest.Capabilities["networkTimeoutSeconds"]
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
|
||||
seconds := parseExtensionTimeoutSeconds(raw)
|
||||
if seconds <= 0 {
|
||||
return fallback
|
||||
}
|
||||
|
||||
if seconds < 5 {
|
||||
seconds = 5
|
||||
}
|
||||
if seconds > 300 {
|
||||
seconds = 300
|
||||
}
|
||||
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func parseExtensionTimeoutSeconds(raw interface{}) int {
|
||||
switch v := raw.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int32:
|
||||
return int(v)
|
||||
case int64:
|
||||
return int(v)
|
||||
case float32:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) setActiveDownloadItemID(itemID string) {
|
||||
r.activeDownloadMu.Lock()
|
||||
defer r.activeDownloadMu.Unlock()
|
||||
r.activeDownloadItemID = strings.TrimSpace(itemID)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) clearActiveDownloadItemID() {
|
||||
func (r *extensionRuntime) clearActiveDownloadItemID() {
|
||||
r.activeDownloadMu.Lock()
|
||||
defer r.activeDownloadMu.Unlock()
|
||||
r.activeDownloadItemID = ""
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getActiveDownloadItemID() string {
|
||||
func (r *extensionRuntime) getActiveDownloadItemID() string {
|
||||
r.activeDownloadMu.RLock()
|
||||
defer r.activeDownloadMu.RUnlock()
|
||||
return r.activeDownloadItemID
|
||||
}
|
||||
|
||||
func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client {
|
||||
func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Request {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
itemID := r.getActiveDownloadItemID()
|
||||
if itemID == "" {
|
||||
return req
|
||||
}
|
||||
|
||||
return req.WithContext(initDownloadCancel(itemID))
|
||||
}
|
||||
|
||||
func newExtensionHTTPClient(ext *loadedExtension, jar http.CookieJar, timeout time.Duration) *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.
|
||||
@@ -329,11 +391,11 @@ func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie {
|
||||
return j.cookies[u.Host]
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
func (r *extensionRuntime) SetSettings(settings map[string]interface{}) {
|
||||
r.settings = settings
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
func (r *extensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
r.vm = vm
|
||||
|
||||
httpObj := vm.NewObject()
|
||||
@@ -377,7 +439,9 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
fileObj.Set("exists", r.fileExists)
|
||||
fileObj.Set("delete", r.fileDelete)
|
||||
fileObj.Set("read", r.fileRead)
|
||||
fileObj.Set("readBytes", r.fileReadBytes)
|
||||
fileObj.Set("write", r.fileWrite)
|
||||
fileObj.Set("writeBytes", r.fileWriteBytes)
|
||||
fileObj.Set("copy", r.fileCopy)
|
||||
fileObj.Set("move", r.fileMove)
|
||||
fileObj.Set("getSize", r.fileGetSize)
|
||||
@@ -407,8 +471,14 @@ func (r *ExtensionRuntime) RegisterAPIs(vm *goja.Runtime) {
|
||||
utilsObj.Set("stringifyJSON", r.stringifyJSON)
|
||||
utilsObj.Set("encrypt", r.cryptoEncrypt)
|
||||
utilsObj.Set("decrypt", r.cryptoDecrypt)
|
||||
utilsObj.Set("encryptBlockCipher", r.encryptBlockCipher)
|
||||
utilsObj.Set("decryptBlockCipher", r.decryptBlockCipher)
|
||||
utilsObj.Set("generateKey", r.cryptoGenerateKey)
|
||||
utilsObj.Set("randomUserAgent", r.randomUserAgent)
|
||||
utilsObj.Set("appVersion", r.appVersion)
|
||||
utilsObj.Set("appUserAgent", r.appUserAgent)
|
||||
utilsObj.Set("sleep", r.sleep)
|
||||
utilsObj.Set("isDownloadCancelled", r.isDownloadCancelled)
|
||||
vm.Set("utils", utilsObj)
|
||||
|
||||
logObj := vm.NewObject()
|
||||
|
||||
@@ -52,7 +52,7 @@ func summarizeURLForLog(urlStr string) string {
|
||||
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -99,7 +99,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
@@ -111,7 +111,7 @@ func (r *ExtensionRuntime) authGetCode(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(state.AuthCode)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func (r *ExtensionRuntime) authSetCode(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.Lock()
|
||||
delete(extensionAuthState, r.extensionID)
|
||||
extensionAuthStateMu.Unlock()
|
||||
@@ -162,7 +162,7 @@ func (r *ExtensionRuntime) authClear(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
@@ -178,7 +178,7 @@ func (r *ExtensionRuntime) authIsAuthenticated(call goja.FunctionCall) goja.Valu
|
||||
return r.vm.ToValue(state.IsAuthenticated)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
@@ -228,7 +228,7 @@ func generatePKCEChallenge(verifier string) string {
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
length := 64
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
if l, ok := call.Arguments[0].Export().(float64); ok && l >= 43 && l <= 128 {
|
||||
@@ -265,7 +265,7 @@ func (r *ExtensionRuntime) authGeneratePKCE(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
extensionAuthStateMu.RLock()
|
||||
defer extensionAuthStateMu.RUnlock()
|
||||
|
||||
@@ -281,7 +281,7 @@ func (r *ExtensionRuntime) authGetPKCE(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -385,7 +385,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -458,6 +458,7 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "SpotiFLAC-Extension/1.0")
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"golang.org/x/crypto/blowfish"
|
||||
)
|
||||
|
||||
type runtimeBlockCipherOptions struct {
|
||||
Algorithm string
|
||||
Mode string
|
||||
Key []byte
|
||||
IV []byte
|
||||
InputEncoding string
|
||||
OutputEncoding string
|
||||
Padding string
|
||||
}
|
||||
|
||||
func parseRuntimeOptionsArgument(call goja.FunctionCall, index int) map[string]interface{} {
|
||||
if len(call.Arguments) <= index {
|
||||
return nil
|
||||
}
|
||||
|
||||
value := call.Arguments[index]
|
||||
if goja.IsUndefined(value) || goja.IsNull(value) {
|
||||
return nil
|
||||
}
|
||||
|
||||
exported := value.Export()
|
||||
if options, ok := exported.(map[string]interface{}); ok {
|
||||
return options
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runtimeOptionString(options map[string]interface{}, key, defaultValue string) string {
|
||||
if options == nil {
|
||||
return defaultValue
|
||||
}
|
||||
raw, ok := options[key]
|
||||
if !ok || raw == nil {
|
||||
return defaultValue
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
case []byte:
|
||||
if len(value) > 0 {
|
||||
return string(value)
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func runtimeOptionBool(options map[string]interface{}, key string, defaultValue bool) bool {
|
||||
if options == nil {
|
||||
return defaultValue
|
||||
}
|
||||
raw, ok := options[key]
|
||||
if !ok || raw == nil {
|
||||
return defaultValue
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case bool:
|
||||
return value
|
||||
case int:
|
||||
return value != 0
|
||||
case int64:
|
||||
return value != 0
|
||||
case float64:
|
||||
return value != 0
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func runtimeOptionInt64(options map[string]interface{}, key string, defaultValue int64) int64 {
|
||||
if options == nil {
|
||||
return defaultValue
|
||||
}
|
||||
raw, ok := options[key]
|
||||
if !ok || raw == nil {
|
||||
return defaultValue
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case int:
|
||||
return int64(value)
|
||||
case int32:
|
||||
return int64(value)
|
||||
case int64:
|
||||
return value
|
||||
case float32:
|
||||
return int64(value)
|
||||
case float64:
|
||||
return int64(value)
|
||||
case string:
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
var parsed int64
|
||||
if _, err := fmt.Sscanf(value, "%d", &parsed); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func runtimeOptionHasKey(options map[string]interface{}, key string) bool {
|
||||
if options == nil {
|
||||
return false
|
||||
}
|
||||
_, exists := options[key]
|
||||
return exists
|
||||
}
|
||||
|
||||
func decodeRuntimeBytesString(input, encoding string) ([]byte, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||
case "", "utf8", "utf-8", "text":
|
||||
return []byte(input), nil
|
||||
case "base64":
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(input))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid base64 data: %w", err)
|
||||
}
|
||||
return decoded, nil
|
||||
case "hex":
|
||||
decoded, err := hex.DecodeString(strings.TrimSpace(input))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid hex data: %w", err)
|
||||
}
|
||||
return decoded, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported byte encoding: %s", encoding)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeRuntimeBytesValue(raw interface{}, encoding string) ([]byte, error) {
|
||||
switch value := raw.(type) {
|
||||
case string:
|
||||
return decodeRuntimeBytesString(value, encoding)
|
||||
case []byte:
|
||||
cloned := make([]byte, len(value))
|
||||
copy(cloned, value)
|
||||
return cloned, nil
|
||||
case []interface{}:
|
||||
decoded := make([]byte, len(value))
|
||||
for i, item := range value {
|
||||
switch num := item.(type) {
|
||||
case int:
|
||||
decoded[i] = byte(num)
|
||||
case int64:
|
||||
decoded[i] = byte(num)
|
||||
case float64:
|
||||
decoded[i] = byte(int(num))
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported byte array item at index %d", i)
|
||||
}
|
||||
}
|
||||
return decoded, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported byte payload type")
|
||||
}
|
||||
}
|
||||
|
||||
func encodeRuntimeBytes(data []byte, encoding string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(encoding)) {
|
||||
case "", "base64":
|
||||
return base64.StdEncoding.EncodeToString(data), nil
|
||||
case "hex":
|
||||
return hex.EncodeToString(data), nil
|
||||
case "utf8", "utf-8", "text":
|
||||
return string(data), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported byte encoding: %s", encoding)
|
||||
}
|
||||
}
|
||||
|
||||
func parseRuntimeBlockCipherOptions(options map[string]interface{}) (*runtimeBlockCipherOptions, error) {
|
||||
parsed := &runtimeBlockCipherOptions{
|
||||
Algorithm: strings.ToLower(runtimeOptionString(options, "algorithm", "")),
|
||||
Mode: strings.ToLower(runtimeOptionString(options, "mode", "cbc")),
|
||||
InputEncoding: strings.ToLower(runtimeOptionString(options, "inputEncoding", "base64")),
|
||||
OutputEncoding: strings.ToLower(runtimeOptionString(options, "outputEncoding", "base64")),
|
||||
Padding: strings.ToLower(runtimeOptionString(options, "padding", "none")),
|
||||
}
|
||||
if parsed.Algorithm == "" {
|
||||
return nil, fmt.Errorf("algorithm is required")
|
||||
}
|
||||
if parsed.Mode == "" {
|
||||
return nil, fmt.Errorf("mode is required")
|
||||
}
|
||||
|
||||
key, err := decodeRuntimeBytesString(runtimeOptionString(options, "key", ""), runtimeOptionString(options, "keyEncoding", "utf8"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid key: %w", err)
|
||||
}
|
||||
if len(key) == 0 {
|
||||
return nil, fmt.Errorf("key is required")
|
||||
}
|
||||
parsed.Key = key
|
||||
|
||||
iv, err := decodeRuntimeBytesString(runtimeOptionString(options, "iv", ""), runtimeOptionString(options, "ivEncoding", "utf8"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid iv: %w", err)
|
||||
}
|
||||
parsed.IV = iv
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func newRuntimeBlockCipher(options *runtimeBlockCipherOptions) (cipher.Block, error) {
|
||||
switch options.Algorithm {
|
||||
case "blowfish":
|
||||
return blowfish.NewCipher(options.Key)
|
||||
case "aes":
|
||||
return aes.NewCipher(options.Key)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported block cipher algorithm: %s", options.Algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
func applyPKCS7Padding(data []byte, blockSize int) []byte {
|
||||
padding := blockSize - (len(data) % blockSize)
|
||||
if padding == 0 {
|
||||
padding = blockSize
|
||||
}
|
||||
out := make([]byte, len(data)+padding)
|
||||
copy(out, data)
|
||||
for i := len(data); i < len(out); i++ {
|
||||
out[i] = byte(padding)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func removePKCS7Padding(data []byte, blockSize int) ([]byte, error) {
|
||||
if len(data) == 0 || len(data)%blockSize != 0 {
|
||||
return nil, fmt.Errorf("invalid padded payload length")
|
||||
}
|
||||
padding := int(data[len(data)-1])
|
||||
if padding <= 0 || padding > blockSize || padding > len(data) {
|
||||
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||
}
|
||||
for i := len(data) - padding; i < len(data); i++ {
|
||||
if int(data[i]) != padding {
|
||||
return nil, fmt.Errorf("invalid PKCS7 padding")
|
||||
}
|
||||
}
|
||||
return data[:len(data)-padding], nil
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) transformBlockCipher(call goja.FunctionCall, decrypt bool) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "data and options are required",
|
||||
})
|
||||
}
|
||||
|
||||
options := parseRuntimeOptionsArgument(call, 1)
|
||||
parsedOptions, err := parseRuntimeBlockCipherOptions(options)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
if parsedOptions.Mode != "cbc" {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("unsupported block cipher mode: %s", parsedOptions.Mode),
|
||||
})
|
||||
}
|
||||
|
||||
inputData, err := decodeRuntimeBytesValue(call.Arguments[0].Export(), parsedOptions.InputEncoding)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
block, err := newRuntimeBlockCipher(parsedOptions)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
if len(parsedOptions.IV) != block.BlockSize() {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
cipher.NewCBCEncrypter(block, parsedOptions.IV).CryptBlocks(output, data)
|
||||
}
|
||||
|
||||
encoded, err := encodeRuntimeBytes(output, parsedOptions.OutputEncoding)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": encoded,
|
||||
"block_size": block.BlockSize(),
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) encryptBlockCipher(call goja.FunctionCall) goja.Value {
|
||||
return r.transformBlockCipher(call, false)
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) decryptBlockCipher(call goja.FunctionCall) goja.Value {
|
||||
return r.transformBlockCipher(call, true)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func newBinaryTestRuntime(t *testing.T, withFilePermission bool) *goja.Runtime {
|
||||
t.Helper()
|
||||
|
||||
ext := &loadedExtension{
|
||||
ID: "binary-test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "binary-test-ext",
|
||||
Permissions: ExtensionPermissions{
|
||||
File: withFilePermission,
|
||||
},
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := newExtensionRuntime(ext)
|
||||
vm := goja.New()
|
||||
runtime.RegisterAPIs(vm)
|
||||
return vm
|
||||
}
|
||||
|
||||
func decodeJSONResult[T any](t *testing.T, value goja.Value) T {
|
||||
t.Helper()
|
||||
|
||||
var decoded T
|
||||
if err := json.Unmarshal([]byte(value.String()), &decoded); err != nil {
|
||||
t.Fatalf("failed to decode JSON result: %v", err)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_FileByteAPIs(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, true)
|
||||
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var first = file.writeBytes("bytes.bin", "AAEC", {encoding: "base64", truncate: true});
|
||||
if (!first.success) throw new Error(first.error);
|
||||
|
||||
var second = file.writeBytes("bytes.bin", "0304ff", {encoding: "hex", append: true});
|
||||
if (!second.success) throw new Error(second.error);
|
||||
|
||||
var all = file.readBytes("bytes.bin", {encoding: "hex"});
|
||||
if (!all.success) throw new Error(all.error);
|
||||
|
||||
var slice = file.readBytes("bytes.bin", {offset: 2, length: 2, encoding: "hex"});
|
||||
if (!slice.success) throw new Error(slice.error);
|
||||
|
||||
var tail = file.readBytes("bytes.bin", {offset: 6, length: 4, encoding: "hex"});
|
||||
if (!tail.success) throw new Error(tail.error);
|
||||
|
||||
return JSON.stringify({
|
||||
all: all.data,
|
||||
slice: slice.data,
|
||||
size: all.size,
|
||||
sliceBytes: slice.bytes_read,
|
||||
sliceEof: slice.eof,
|
||||
tailBytes: tail.bytes_read,
|
||||
tailEof: tail.eof
|
||||
});
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("file byte APIs failed: %v", err)
|
||||
}
|
||||
|
||||
decoded := decodeJSONResult[struct {
|
||||
All string `json:"all"`
|
||||
Slice string `json:"slice"`
|
||||
Size int64 `json:"size"`
|
||||
SliceBytes int `json:"sliceBytes"`
|
||||
SliceEof bool `json:"sliceEof"`
|
||||
TailBytes int `json:"tailBytes"`
|
||||
TailEof bool `json:"tailEof"`
|
||||
}](t, result)
|
||||
|
||||
if decoded.All != "0001020304ff" {
|
||||
t.Fatalf("all = %q", decoded.All)
|
||||
}
|
||||
if decoded.Slice != "0203" {
|
||||
t.Fatalf("slice = %q", decoded.Slice)
|
||||
}
|
||||
if decoded.Size != 6 {
|
||||
t.Fatalf("size = %d", decoded.Size)
|
||||
}
|
||||
if decoded.SliceBytes != 2 {
|
||||
t.Fatalf("slice bytes = %d", decoded.SliceBytes)
|
||||
}
|
||||
if decoded.SliceEof {
|
||||
t.Fatal("slice should not be EOF")
|
||||
}
|
||||
if decoded.TailBytes != 0 || !decoded.TailEof {
|
||||
t.Fatalf("tail read mismatch: bytes=%d eof=%v", decoded.TailBytes, decoded.TailEof)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_BlockCipherCBCSupportsBlowfish(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, false)
|
||||
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var options = {
|
||||
algorithm: "blowfish",
|
||||
mode: "cbc",
|
||||
key: "0123456789ABCDEFF0E1D2C3B4A59687",
|
||||
keyEncoding: "hex",
|
||||
iv: "0001020304050607",
|
||||
ivEncoding: "hex",
|
||||
inputEncoding: "hex",
|
||||
outputEncoding: "hex",
|
||||
padding: "none"
|
||||
};
|
||||
var enc = utils.encryptBlockCipher("00112233445566778899aabbccddeeff", options);
|
||||
if (!enc.success) throw new Error(enc.error);
|
||||
var dec = utils.decryptBlockCipher(enc.data, options);
|
||||
if (!dec.success) throw new Error(dec.error);
|
||||
return JSON.stringify({enc: enc.data, dec: dec.data});
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("blowfish block cipher failed: %v", err)
|
||||
}
|
||||
|
||||
decoded := decodeJSONResult[struct {
|
||||
Enc string `json:"enc"`
|
||||
Dec string `json:"dec"`
|
||||
}](t, result)
|
||||
|
||||
if decoded.Dec != "00112233445566778899aabbccddeeff" {
|
||||
t.Fatalf("dec = %q", decoded.Dec)
|
||||
}
|
||||
if decoded.Enc == decoded.Dec {
|
||||
t.Fatal("expected ciphertext to differ from plaintext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_BlockCipherCBCSupportsAES(t *testing.T) {
|
||||
vm := newBinaryTestRuntime(t, false)
|
||||
|
||||
result, err := vm.RunString(`
|
||||
(function() {
|
||||
var options = {
|
||||
algorithm: "aes",
|
||||
mode: "cbc",
|
||||
key: "000102030405060708090a0b0c0d0e0f",
|
||||
keyEncoding: "hex",
|
||||
iv: "0f0e0d0c0b0a09080706050403020100",
|
||||
ivEncoding: "hex",
|
||||
inputEncoding: "utf8",
|
||||
outputEncoding: "base64",
|
||||
padding: "pkcs7"
|
||||
};
|
||||
var enc = utils.encryptBlockCipher("hello generic cbc", options);
|
||||
if (!enc.success) throw new Error(enc.error);
|
||||
var dec = utils.decryptBlockCipher(enc.data, {
|
||||
algorithm: "aes",
|
||||
mode: "cbc",
|
||||
key: options.key,
|
||||
keyEncoding: options.keyEncoding,
|
||||
iv: options.iv,
|
||||
ivEncoding: options.ivEncoding,
|
||||
inputEncoding: "base64",
|
||||
outputEncoding: "utf8",
|
||||
padding: "pkcs7"
|
||||
});
|
||||
if (!dec.success) throw new Error(dec.error);
|
||||
return dec.data;
|
||||
})()
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatalf("aes block cipher failed: %v", err)
|
||||
}
|
||||
|
||||
if result.String() != "hello generic cbc" {
|
||||
t.Fatalf("unexpected decrypted value: %q", result.String())
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func ClearFFmpegCommand(commandID string) {
|
||||
delete(ffmpegCommands, commandID)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -107,7 +107,7 @@ func (r *ExtensionRuntime) ffmpegExecute(call goja.FunctionCall) goja.Value {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -134,7 +134,7 @@ func (r *ExtensionRuntime) ffmpegGetInfo(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) ffmpegConvert(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
|
||||
@@ -71,7 +71,7 @@ func isPathWithinBase(baseDir, targetPath string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
func (r *extensionRuntime) validatePath(path string) (string, error) {
|
||||
if !r.manifest.Permissions.File {
|
||||
return "", fmt.Errorf("file access denied: extension does not have 'file' permission")
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func (r *ExtensionRuntime) validatePath(path string) (string, error) {
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -166,6 +166,7 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -271,7 +272,7 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -286,7 +287,7 @@ func (r *ExtensionRuntime) fileExists(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(err == nil)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -315,7 +316,7 @@ func (r *ExtensionRuntime) fileDelete(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -346,7 +347,105 @@ func (r *ExtensionRuntime) fileRead(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileReadBytes(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path is required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
options := parseRuntimeOptionsArgument(call, 1)
|
||||
offset := runtimeOptionInt64(options, "offset", 0)
|
||||
length := runtimeOptionInt64(options, "length", -1)
|
||||
encoding := runtimeOptionString(options, "encoding", "base64")
|
||||
if offset < 0 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "offset must be >= 0",
|
||||
})
|
||||
}
|
||||
|
||||
file, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
size := info.Size()
|
||||
if offset > size {
|
||||
offset = size
|
||||
}
|
||||
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to seek file: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
var data []byte
|
||||
switch {
|
||||
case length == 0:
|
||||
data = []byte{}
|
||||
case length > 0:
|
||||
buf := make([]byte, int(length))
|
||||
n, readErr := file.Read(buf)
|
||||
if readErr != nil && readErr != io.EOF {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read file: %v", readErr),
|
||||
})
|
||||
}
|
||||
data = buf[:n]
|
||||
default:
|
||||
data, err = io.ReadAll(file)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to read file: %v", err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
encoded, err := encodeRuntimeBytes(data, encoding)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"data": encoded,
|
||||
"bytes_read": len(data),
|
||||
"offset": offset,
|
||||
"size": size,
|
||||
"eof": offset+int64(len(data)) >= size,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -386,7 +485,108 @@ func (r *ExtensionRuntime) fileWrite(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileWriteBytes(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "path and data are required",
|
||||
})
|
||||
}
|
||||
|
||||
path := call.Arguments[0].String()
|
||||
fullPath, err := r.validatePath(path)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
options := parseRuntimeOptionsArgument(call, 2)
|
||||
appendMode := runtimeOptionBool(options, "append", false)
|
||||
truncate := runtimeOptionBool(options, "truncate", false)
|
||||
hasOffset := runtimeOptionHasKey(options, "offset")
|
||||
offset := runtimeOptionInt64(options, "offset", 0)
|
||||
encoding := runtimeOptionString(options, "encoding", "base64")
|
||||
|
||||
if appendMode && hasOffset {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "append and offset cannot be used together",
|
||||
})
|
||||
}
|
||||
if offset < 0 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": "offset must be >= 0",
|
||||
})
|
||||
}
|
||||
|
||||
data, err := decodeRuntimeBytesValue(call.Arguments[1].Export(), encoding)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
dir := filepath.Dir(fullPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to create directory: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
flags := os.O_CREATE | os.O_WRONLY
|
||||
if appendMode {
|
||||
flags |= os.O_APPEND
|
||||
}
|
||||
if truncate {
|
||||
flags |= os.O_TRUNC
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(fullPath, flags, 0644)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if hasOffset && !appendMode {
|
||||
if _, err := file.Seek(offset, io.SeekStart); err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": fmt.Sprintf("failed to seek file: %v", err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
written, err := file.Write(data)
|
||||
if err != nil {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
info, statErr := file.Stat()
|
||||
size := int64(0)
|
||||
if statErr == nil {
|
||||
size = info.Size()
|
||||
}
|
||||
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": true,
|
||||
"path": fullPath,
|
||||
"bytes_written": written,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -459,7 +659,7 @@ func (r *ExtensionRuntime) fileCopy(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -507,7 +707,7 @@ func (r *ExtensionRuntime) fileMove(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fileGetSize(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
|
||||
@@ -17,7 +17,7 @@ type HTTPResponse struct {
|
||||
Headers map[string]string `json:"headers"`
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
func (r *extensionRuntime) validateDomain(urlStr string) error {
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
@@ -49,7 +49,7 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
@@ -81,6 +81,7 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -124,7 +125,7 @@ func (r *ExtensionRuntime) httpGet(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
@@ -175,6 +176,7 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -221,7 +223,7 @@ func (r *ExtensionRuntime) httpPost(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
@@ -284,6 +286,7 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -330,19 +333,19 @@ func (r *ExtensionRuntime) httpRequest(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpPut(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PUT", call)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpDelete(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("DELETE", call)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpPatch(call goja.FunctionCall) goja.Value {
|
||||
return r.httpMethodShortcut("PATCH", call)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpMethodShortcut(method string, call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"error": "URL is required",
|
||||
@@ -410,6 +413,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -455,7 +459,7 @@ func (r *ExtensionRuntime) httpMethodShortcut(method string, call goja.FunctionC
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) httpClearCookies(call goja.FunctionCall) goja.Value {
|
||||
if jar, ok := r.cookieJar.(*simpleCookieJar); ok {
|
||||
jar.mu.Lock()
|
||||
jar.cookies = make(map[string][]*http.Cookie)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(0.0)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.V
|
||||
return r.vm.ToValue(similarity)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func (r *ExtensionRuntime) matchingCompareDuration(call goja.FunctionCall) goja.
|
||||
return r.vm.ToValue(diff <= tolerance)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) matchingNormalizeString(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.createFetchError("URL is required")
|
||||
}
|
||||
@@ -69,6 +69,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if err != nil {
|
||||
return r.createFetchError(err.Error())
|
||||
}
|
||||
req = r.bindDownloadCancelContext(req)
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
@@ -133,7 +134,7 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value {
|
||||
return responseObj
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
||||
func (r *extensionRuntime) createFetchError(message string) goja.Value {
|
||||
errorObj := r.vm.NewObject()
|
||||
errorObj.Set("ok", false)
|
||||
errorObj.Set("status", 0)
|
||||
@@ -148,7 +149,7 @@ func (r *ExtensionRuntime) createFetchError(message string) goja.Value {
|
||||
return errorObj
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -164,7 +165,7 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(string(decoded))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -172,7 +173,7 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
func (r *extensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object {
|
||||
encoder := call.This
|
||||
encoder.Set("encoding", "utf-8")
|
||||
@@ -252,7 +253,7 @@ func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
func (r *extensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
vm.Set("URL", func(call goja.ConstructorCall) *goja.Object {
|
||||
urlObj := call.This
|
||||
|
||||
@@ -416,7 +417,7 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||
func (r *extensionRuntime) registerJSONGlobal(vm *goja.Runtime) {
|
||||
jsonScript := `
|
||||
if (typeof JSON === 'undefined') {
|
||||
var JSON = {
|
||||
|
||||
@@ -21,7 +21,7 @@ const (
|
||||
storageFlushRetryDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
func (r *ExtensionRuntime) getStoragePath() string {
|
||||
func (r *extensionRuntime) getStoragePath() string {
|
||||
return filepath.Join(r.dataDir, "storage.json")
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func cloneInterfaceMap(src map[string]interface{}) map[string]interface{} {
|
||||
return dst
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ensureStorageLoaded() error {
|
||||
func (r *extensionRuntime) ensureStorageLoaded() error {
|
||||
r.storageMu.RLock()
|
||||
if r.storageLoaded {
|
||||
r.storageMu.RUnlock()
|
||||
@@ -74,7 +74,7 @@ func (r *ExtensionRuntime) ensureStorageLoaded() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||
func (r *extensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||
if err := r.ensureStorageLoaded(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func (r *ExtensionRuntime) loadStorage() (map[string]interface{}, error) {
|
||||
return cloneInterfaceMap(r.storageCache), nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
||||
func (r *extensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
||||
if r.storageClosed {
|
||||
return
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func (r *ExtensionRuntime) queueStorageFlushLocked(delay time.Duration) {
|
||||
r.storageTimer = time.AfterFunc(delay, r.flushStorageDirtyAsync)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
|
||||
func (r *extensionRuntime) persistStorageSnapshot(storage map[string]interface{}) error {
|
||||
data, err := json.Marshal(storage)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -106,13 +106,13 @@ func (r *ExtensionRuntime) persistStorageSnapshot(storage map[string]interface{}
|
||||
return os.WriteFile(r.getStoragePath(), data, 0600)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) flushStorageDirtyAsync() {
|
||||
func (r *extensionRuntime) flushStorageDirtyAsync() {
|
||||
if err := r.flushStorageDirty(); err != nil {
|
||||
GoLog("[Extension:%s] Storage flush error: %v\n", r.extensionID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) flushStorageDirty() error {
|
||||
func (r *extensionRuntime) flushStorageDirty() error {
|
||||
r.storageMu.Lock()
|
||||
if r.storageClosed {
|
||||
r.storageTimer = nil
|
||||
@@ -140,7 +140,7 @@ func (r *ExtensionRuntime) flushStorageDirty() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) flushStorageNow() error {
|
||||
func (r *extensionRuntime) flushStorageNow() error {
|
||||
r.storageMu.Lock()
|
||||
if r.storageTimer != nil {
|
||||
r.storageTimer.Stop()
|
||||
@@ -157,7 +157,7 @@ func (r *ExtensionRuntime) flushStorageNow() error {
|
||||
return r.persistStorageSnapshot(snapshot)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) closeStorageFlusher() {
|
||||
func (r *extensionRuntime) closeStorageFlusher() {
|
||||
r.storageMu.Lock()
|
||||
r.storageClosed = true
|
||||
r.storageDirty = false
|
||||
@@ -168,7 +168,7 @@ func (r *ExtensionRuntime) closeStorageFlusher() {
|
||||
r.storageMu.Unlock()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(value)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -225,7 +225,7 @@ func (r *ExtensionRuntime) storageSet(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -254,15 +254,15 @@ func (r *ExtensionRuntime) storageRemove(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getCredentialsPath() string {
|
||||
func (r *extensionRuntime) getCredentialsPath() string {
|
||||
return filepath.Join(r.dataDir, ".credentials.enc")
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getSaltPath() string {
|
||||
func (r *extensionRuntime) getSaltPath() string {
|
||||
return filepath.Join(r.dataDir, ".cred_salt")
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
func (r *extensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
saltPath := r.getSaltPath()
|
||||
|
||||
salt, err := os.ReadFile(saltPath)
|
||||
@@ -282,7 +282,7 @@ func (r *ExtensionRuntime) getOrCreateSalt() ([]byte, error) {
|
||||
return salt, nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||
func (r *extensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||
salt, err := r.getOrCreateSalt()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -293,7 +293,7 @@ func (r *ExtensionRuntime) getEncryptionKey() ([]byte, error) {
|
||||
return hash[:], nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
|
||||
func (r *extensionRuntime) ensureCredentialsLoaded() error {
|
||||
r.credentialsMu.RLock()
|
||||
if r.credentialsLoaded {
|
||||
r.credentialsMu.RUnlock()
|
||||
@@ -340,7 +340,7 @@ func (r *ExtensionRuntime) ensureCredentialsLoaded() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
func (r *extensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
if err := r.ensureCredentialsLoaded(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -350,7 +350,7 @@ func (r *ExtensionRuntime) loadCredentials() (map[string]interface{}, error) {
|
||||
return cloneInterfaceMap(r.credentialsCache), nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
func (r *extensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
data, err := json.Marshal(creds)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -377,7 +377,7 @@ func (r *ExtensionRuntime) saveCredentials(creds map[string]interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -414,7 +414,7 @@ func (r *ExtensionRuntime) credentialsStore(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
@@ -439,7 +439,7 @@ func (r *ExtensionRuntime) credentialsGet(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(value)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
@@ -464,7 +464,7 @@ func (r *ExtensionRuntime) credentialsRemove(call goja.FunctionCall) goja.Value
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) credentialsHas(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func setStorageValue(t *testing.T, runtime *ExtensionRuntime, key string, value interface{}) {
|
||||
func setStorageValue(t *testing.T, runtime *extensionRuntime, key string, value interface{}) {
|
||||
t.Helper()
|
||||
result := runtime.storageSet(goja.FunctionCall{
|
||||
Arguments: []goja.Value{
|
||||
@@ -39,7 +39,7 @@ func readStorageMap(t *testing.T, storagePath string) map[string]interface{} {
|
||||
}
|
||||
|
||||
func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: "storage-test",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "storage-test",
|
||||
@@ -47,7 +47,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
runtime.storageFlushDelay = 25 * time.Millisecond
|
||||
runtime.RegisterAPIs(goja.New())
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestExtensionRuntimeStorage_DebouncedWriteCompactJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: "unload-storage-test",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "unload-storage-test",
|
||||
@@ -95,13 +95,13 @@ func TestUnloadExtension_FlushesPendingStorage(t *testing.T) {
|
||||
VM: goja.New(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
runtime.storageFlushDelay = time.Hour
|
||||
runtime.RegisterAPIs(ext.VM)
|
||||
ext.runtime = runtime
|
||||
|
||||
manager := &ExtensionManager{
|
||||
extensions: map[string]*LoadedExtension{
|
||||
manager := &extensionManager{
|
||||
extensions: map[string]*loadedExtension{
|
||||
ext.ID: ext,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -24,7 +24,7 @@ func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input)))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -36,7 +36,7 @@ func (r *ExtensionRuntime) base64Decode(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(string(decoded))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func (r *ExtensionRuntime) md5Hash(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func (r *ExtensionRuntime) sha256Hash(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(hex.EncodeToString(hash[:]))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func (r *ExtensionRuntime) hmacSHA256(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func (r *ExtensionRuntime) hmacSHA256Base64(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(base64.StdEncoding.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue([]byte{})
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func (r *ExtensionRuntime) hmacSHA1(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(jsArray)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return goja.Undefined()
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func (r *ExtensionRuntime) parseJSON(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(result)
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func (r *ExtensionRuntime) stringifyJSON(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(string(data))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -187,7 +187,7 @@ func (r *ExtensionRuntime) cryptoEncrypt(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 2 {
|
||||
return r.vm.ToValue(map[string]interface{}{
|
||||
"success": false,
|
||||
@@ -222,7 +222,7 @@ func (r *ExtensionRuntime) cryptoDecrypt(call goja.FunctionCall) goja.Value {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value {
|
||||
length := 32
|
||||
if len(call.Arguments) > 0 && !goja.IsUndefined(call.Arguments[0]) {
|
||||
if l, ok := call.Arguments[0].Export().(float64); ok {
|
||||
@@ -245,35 +245,98 @@ func (r *ExtensionRuntime) cryptoGenerateKey(call goja.FunctionCall) goja.Value
|
||||
})
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) randomUserAgent(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(getRandomUserAgent())
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) appVersion(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(GetAppVersion())
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) appUserAgent(call goja.FunctionCall) goja.Value {
|
||||
return r.vm.ToValue(appUserAgent())
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) sleep(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
sleepMs := 0
|
||||
switch value := call.Arguments[0].Export().(type) {
|
||||
case int64:
|
||||
sleepMs = int(value)
|
||||
case int32:
|
||||
sleepMs = int(value)
|
||||
case int:
|
||||
sleepMs = value
|
||||
case float64:
|
||||
sleepMs = int(value)
|
||||
default:
|
||||
sleepMs = 0
|
||||
}
|
||||
|
||||
if sleepMs <= 0 {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
if sleepMs > 5*60*1000 {
|
||||
sleepMs = 5 * 60 * 1000
|
||||
}
|
||||
|
||||
itemID := r.getActiveDownloadItemID()
|
||||
deadline := time.Now().Add(time.Duration(sleepMs) * time.Millisecond)
|
||||
|
||||
for {
|
||||
if itemID != "" && isDownloadCancelled(itemID) {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
|
||||
remaining := time.Until(deadline)
|
||||
if remaining <= 0 {
|
||||
return r.vm.ToValue(true)
|
||||
}
|
||||
|
||||
step := 100 * time.Millisecond
|
||||
if remaining < step {
|
||||
step = remaining
|
||||
}
|
||||
time.Sleep(step)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) isDownloadCancelled(call goja.FunctionCall) goja.Value {
|
||||
itemID := r.getActiveDownloadItemID()
|
||||
if itemID == "" {
|
||||
return r.vm.ToValue(false)
|
||||
}
|
||||
return r.vm.ToValue(isDownloadCancelled(itemID))
|
||||
}
|
||||
|
||||
func (r *extensionRuntime) logDebug(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:DEBUG] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) logInfo(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:INFO] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) logWarn(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:WARN] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) logError(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) logError(call goja.FunctionCall) goja.Value {
|
||||
msg := r.formatLogArgs(call.Arguments)
|
||||
GoLog("[Extension:%s:ERROR] %s\n", r.extensionID, msg)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||
func (r *extensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||
parts := make([]string, len(args))
|
||||
for i, arg := range args {
|
||||
parts[i] = fmt.Sprintf("%v", arg.Export())
|
||||
@@ -281,7 +344,7 @@ func (r *ExtensionRuntime) formatLogArgs(args []goja.Value) string {
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
||||
func (r *extensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
return r.vm.ToValue("")
|
||||
}
|
||||
@@ -289,7 +352,7 @@ func (r *ExtensionRuntime) sanitizeFilenameWrapper(call goja.FunctionCall) goja.
|
||||
return r.vm.ToValue(sanitizeFilename(input))
|
||||
}
|
||||
|
||||
func (r *ExtensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) {
|
||||
gobackendObj := vm.Get("gobackend")
|
||||
if gobackendObj == nil || goja.IsUndefined(gobackendObj) {
|
||||
gobackendObj = vm.NewObject()
|
||||
|
||||
@@ -26,7 +26,6 @@ type storeExtension struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
@@ -83,7 +82,6 @@ type storeExtensionResponse struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
@@ -103,7 +101,6 @@ func (e *storeExtension) toResponse() storeExtensionResponse {
|
||||
Name: e.Name,
|
||||
DisplayName: e.getDisplayName(),
|
||||
Version: e.Version,
|
||||
Author: e.Author,
|
||||
Description: e.Description,
|
||||
DownloadURL: e.getDownloadURL(),
|
||||
IconURL: e.getIconURL(),
|
||||
@@ -253,7 +250,17 @@ func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error
|
||||
LogInfo("ExtensionStore", "Fetching registry from %s", s.registryURL)
|
||||
|
||||
client := NewHTTPClientWithTimeout(30 * time.Second)
|
||||
resp, err := client.Get(s.registryURL)
|
||||
req, err := http.NewRequest(http.MethodGet, s.registryURL, nil)
|
||||
if err != nil {
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionStore", "Failed to build registry request, using cached registry: %v", err)
|
||||
return s.cache, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to build registry request: %w", err)
|
||||
}
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
req.Header.Set("Pragma", "no-cache")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if s.cache != nil {
|
||||
LogWarn("ExtensionStore", "Network error, using cached registry: %v", err)
|
||||
@@ -295,7 +302,7 @@ func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExte
|
||||
return nil, err
|
||||
}
|
||||
|
||||
manager := GetExtensionManager()
|
||||
manager := getExtensionManager()
|
||||
installed := make(map[string]string) // id -> version
|
||||
|
||||
if manager != nil {
|
||||
@@ -348,7 +355,13 @@ func (s *extensionStore) downloadExtension(extensionID string, destPath string)
|
||||
LogInfo("ExtensionStore", "Downloading %s from %s", ext.getDisplayName(), ext.getDownloadURL())
|
||||
|
||||
client := NewHTTPClientWithTimeout(5 * time.Minute)
|
||||
resp, err := client.Get(ext.getDownloadURL())
|
||||
req, err := http.NewRequest(http.MethodGet, ext.getDownloadURL(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build download request: %w", err)
|
||||
}
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
req.Header.Set("Pragma", "no-cache")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download: %w", err)
|
||||
}
|
||||
@@ -481,8 +494,7 @@ func (s *extensionStore) searchExtensions(query string, category string) ([]stor
|
||||
if query != "" {
|
||||
if !containsIgnoreCase(ext.Name, queryLower) &&
|
||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||
!containsIgnoreCase(ext.Description, queryLower) &&
|
||||
!containsIgnoreCase(ext.Author, queryLower) {
|
||||
!containsIgnoreCase(ext.Description, queryLower) {
|
||||
found := false
|
||||
for _, tag := range ext.Tags {
|
||||
if containsIgnoreCase(tag, queryLower) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
@@ -12,7 +14,6 @@ func TestParseManifest_Valid(t *testing.T) {
|
||||
"name": "test-provider",
|
||||
"displayName": "Test Provider",
|
||||
"version": "1.0.0",
|
||||
"author": "Test Author",
|
||||
"description": "A test extension",
|
||||
"type": ["metadata_provider"],
|
||||
"permissions": {
|
||||
@@ -46,7 +47,6 @@ func TestParseManifest_Valid(t *testing.T) {
|
||||
func TestParseManifest_MissingName(t *testing.T) {
|
||||
invalidManifest := `{
|
||||
"version": "1.0.0",
|
||||
"author": "Test Author",
|
||||
"description": "A test extension",
|
||||
"type": ["metadata_provider"]
|
||||
}`
|
||||
@@ -61,7 +61,6 @@ func TestParseManifest_MissingType(t *testing.T) {
|
||||
invalidManifest := `{
|
||||
"name": "test-provider",
|
||||
"version": "1.0.0",
|
||||
"author": "Test Author",
|
||||
"description": "A test extension"
|
||||
}`
|
||||
|
||||
@@ -99,7 +98,7 @@ func TestIsDomainAllowed(t *testing.T) {
|
||||
|
||||
func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
// Create a mock extension with limited network permissions
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
@@ -110,7 +109,7 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
|
||||
if err := runtime.validateDomain("https://api.allowed.com/path"); err != nil {
|
||||
t.Errorf("Expected api.allowed.com to be allowed, got error: %v", err)
|
||||
@@ -132,7 +131,7 @@ func TestExtensionRuntime_NetworkSandbox(t *testing.T) {
|
||||
func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
@@ -143,7 +142,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
DataDir: tempDir,
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
|
||||
validPath, err := runtime.validatePath("test.txt")
|
||||
if err != nil {
|
||||
@@ -177,7 +176,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
t.Error("Expected absolute path to be blocked")
|
||||
}
|
||||
|
||||
extNoFile := &LoadedExtension{
|
||||
extNoFile := &loadedExtension{
|
||||
ID: "test-ext-no-file",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext-no-file",
|
||||
@@ -187,7 +186,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
},
|
||||
DataDir: tempDir,
|
||||
}
|
||||
runtimeNoFile := NewExtensionRuntime(extNoFile)
|
||||
runtimeNoFile := newExtensionRuntime(extNoFile)
|
||||
_, err = runtimeNoFile.validatePath("test.txt")
|
||||
if err == nil {
|
||||
t.Error("Expected file access to be denied without file permission")
|
||||
@@ -195,7 +194,7 @@ func TestExtensionRuntime_FileSandbox(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
@@ -203,7 +202,7 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
vm := goja.New()
|
||||
runtime.RegisterAPIs(vm)
|
||||
|
||||
@@ -239,11 +238,133 @@ func TestExtensionRuntime_UtilityFunctions(t *testing.T) {
|
||||
if result.String() == "" {
|
||||
t.Error("Expected non-empty JSON string")
|
||||
}
|
||||
|
||||
result, err = vm.RunString(`utils.sleep(1)`)
|
||||
if err != nil {
|
||||
t.Fatalf("sleep failed: %v", err)
|
||||
}
|
||||
if !result.ToBoolean() {
|
||||
t.Error("Expected sleep to complete successfully")
|
||||
}
|
||||
|
||||
runtime.setActiveDownloadItemID("test-item")
|
||||
cancelDownload("test-item")
|
||||
t.Cleanup(func() {
|
||||
clearDownloadCancel("test-item")
|
||||
runtime.clearActiveDownloadItemID()
|
||||
})
|
||||
|
||||
result, err = vm.RunString(`utils.isDownloadCancelled()`)
|
||||
if err != nil {
|
||||
t.Fatalf("isDownloadCancelled failed: %v", err)
|
||||
}
|
||||
if !result.ToBoolean() {
|
||||
t.Error("Expected active download cancellation to be visible to JS")
|
||||
}
|
||||
|
||||
SetAppVersion("4.2.2")
|
||||
t.Cleanup(func() {
|
||||
SetAppVersion("")
|
||||
})
|
||||
|
||||
result, err = vm.RunString(`utils.appVersion()`)
|
||||
if err != nil {
|
||||
t.Fatalf("appVersion failed: %v", err)
|
||||
}
|
||||
if got := result.String(); got != "4.2.2" {
|
||||
t.Fatalf("Expected appVersion 4.2.2, got %q", got)
|
||||
}
|
||||
|
||||
result, err = vm.RunString(`utils.appUserAgent()`)
|
||||
if err != nil {
|
||||
t.Fatalf("appUserAgent failed: %v", err)
|
||||
}
|
||||
if got := result.String(); got != "SpotiFLAC-Mobile/4.2.2" {
|
||||
t.Fatalf("Expected appUserAgent SpotiFLAC-Mobile/4.2.2, got %q", got)
|
||||
}
|
||||
|
||||
result, err = vm.RunString(`utils.sleep(50)`)
|
||||
if err != nil {
|
||||
t.Fatalf("cancel-aware sleep failed: %v", err)
|
||||
}
|
||||
if result.ToBoolean() {
|
||||
t.Error("Expected sleep to abort when download is cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_BindDownloadCancelContext(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := newExtensionRuntime(ext)
|
||||
runtime.setActiveDownloadItemID("test-item")
|
||||
t.Cleanup(func() {
|
||||
clearDownloadCancel("test-item")
|
||||
runtime.clearActiveDownloadItemID()
|
||||
})
|
||||
|
||||
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest failed: %v", err)
|
||||
}
|
||||
|
||||
req = runtime.bindDownloadCancelContext(req)
|
||||
cancelDownload("test-item")
|
||||
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("Expected bound request context to be cancelled")
|
||||
}
|
||||
|
||||
if req.Context().Err() == nil {
|
||||
t.Fatal("Expected request context error after cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_BindDownloadCancelContextPreservesPreCancelledState(t *testing.T) {
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
},
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := newExtensionRuntime(ext)
|
||||
runtime.setActiveDownloadItemID("test-item")
|
||||
cancelDownload("test-item")
|
||||
t.Cleanup(func() {
|
||||
clearDownloadCancel("test-item")
|
||||
runtime.clearActiveDownloadItemID()
|
||||
})
|
||||
|
||||
req, err := http.NewRequest("GET", "https://api.example.com/test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest failed: %v", err)
|
||||
}
|
||||
|
||||
req = runtime.bindDownloadCancelContext(req)
|
||||
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("Expected pre-cancelled request context to stay cancelled")
|
||||
}
|
||||
|
||||
if req.Context().Err() == nil {
|
||||
t.Fatal("Expected request context error for pre-cancelled item")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||
// Create extension with limited network permissions
|
||||
ext := &LoadedExtension{
|
||||
ext := &loadedExtension{
|
||||
ID: "test-ext",
|
||||
Manifest: &ExtensionManifest{
|
||||
Name: "test-ext",
|
||||
@@ -254,7 +375,7 @@ func TestExtensionRuntime_SSRFProtection(t *testing.T) {
|
||||
DataDir: t.TempDir(),
|
||||
}
|
||||
|
||||
runtime := NewExtensionRuntime(ext)
|
||||
runtime := newExtensionRuntime(ext)
|
||||
|
||||
privateIPs := []string{
|
||||
"http://localhost/admin",
|
||||
|
||||
@@ -53,7 +53,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
IsTimeout: true,
|
||||
}}
|
||||
} else {
|
||||
GoLog("[ExtensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
|
||||
GoLog("[extensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
|
||||
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
|
||||
case <-time.After(60 * time.Second):
|
||||
// Goroutine is truly stuck (e.g. HTTP read with no timeout).
|
||||
// Log a warning — the VM should NOT be reused after this.
|
||||
GoLog("[ExtensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
|
||||
GoLog("[extensionRuntime] WARNING: JS goroutine did not exit within 60s after interrupt, VM may be unsafe\n")
|
||||
return nil, &JSExecutionError{
|
||||
Message: "execution timeout exceeded (force)",
|
||||
IsTimeout: true,
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -17,19 +19,42 @@ var (
|
||||
)
|
||||
|
||||
func sanitizeFilename(filename string) string {
|
||||
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
||||
sanitized := strings.ReplaceAll(filename, "/", " ")
|
||||
sanitized = invalidChars.ReplaceAllString(sanitized, " ")
|
||||
|
||||
var builder strings.Builder
|
||||
for _, r := range sanitized {
|
||||
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
|
||||
continue
|
||||
}
|
||||
if r == 0x7F {
|
||||
continue
|
||||
}
|
||||
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
|
||||
continue
|
||||
}
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
|
||||
sanitized = builder.String()
|
||||
sanitized = strings.TrimSpace(sanitized)
|
||||
sanitized = strings.Trim(sanitized, ".")
|
||||
|
||||
sanitized = strings.Trim(sanitized, ". ")
|
||||
sanitized = strings.Join(strings.Fields(sanitized), " ")
|
||||
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
||||
sanitized = strings.Trim(sanitized, "_ ")
|
||||
|
||||
if !utf8.ValidString(sanitized) {
|
||||
sanitized = strings.ToValidUTF8(sanitized, "_")
|
||||
}
|
||||
|
||||
if len(sanitized) > 200 {
|
||||
sanitized = sanitized[:200]
|
||||
sanitized = strings.TrimSpace(strings.Trim(sanitized, ". "))
|
||||
sanitized = strings.Trim(sanitized, "_ ")
|
||||
}
|
||||
|
||||
if sanitized == "" {
|
||||
sanitized = "untitled"
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
return sanitized
|
||||
|
||||
@@ -83,3 +83,18 @@ func TestBuildFilenameFromTemplate_DateStrftimeFormattingWithYearOnly(t *testing
|
||||
t.Fatalf("expected %q, got %q", expected, formatted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFilenameMatchesDesktopSpacingBehavior(t *testing.T) {
|
||||
got := sanitizeFilename(` "Text In Quotes"?%* / Demo `)
|
||||
want := "Text In Quotes % Demo"
|
||||
if got != want {
|
||||
t.Fatalf("expected %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFilenameFallsBackToUnknownWhenEmpty(t *testing.T) {
|
||||
got := sanitizeFilename(`<>:"/\|?*`)
|
||||
if got != "Unknown" {
|
||||
t.Fatalf("expected %q, got %q", "Unknown", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,19 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func userAgentForURL(u *url.URL) string {
|
||||
if u == nil {
|
||||
return getRandomUserAgent()
|
||||
}
|
||||
|
||||
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
|
||||
if host == "api.zarz.moe" {
|
||||
return appUserAgent()
|
||||
}
|
||||
|
||||
return getRandomUserAgent()
|
||||
}
|
||||
|
||||
func getRandomUserAgent() string {
|
||||
chromeVersion := rand.Intn(26) + 120
|
||||
chromeBuild := rand.Intn(1500) + 6000
|
||||
@@ -225,7 +238,7 @@ func cloneRequestWithHTTPScheme(req *http.Request, scheme string) (*http.Request
|
||||
}
|
||||
|
||||
func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", userAgentForURL(req.URL))
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||
@@ -255,7 +268,7 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
|
||||
for attempt := 0; attempt <= config.MaxRetries; attempt++ {
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
|
||||
|
||||
resp, err := client.Do(reqCopy)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,7 +11,7 @@ func GetCloudflareBypassClient() *http.Client {
|
||||
}
|
||||
|
||||
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", userAgentForURL(req.URL))
|
||||
resp, err := sharedClient.Do(req)
|
||||
if err != nil {
|
||||
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||
|
||||
@@ -101,7 +101,7 @@ func GetCloudflareBypassClient() *http.Client {
|
||||
}
|
||||
|
||||
func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", userAgentForURL(req.URL))
|
||||
|
||||
resp, err := sharedClient.Do(req)
|
||||
if err == nil {
|
||||
@@ -129,7 +129,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...")
|
||||
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
|
||||
|
||||
return cloudflareBypassClient.Do(reqCopy)
|
||||
}
|
||||
@@ -155,7 +155,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
|
||||
LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err)
|
||||
|
||||
reqCopy := req.Clone(req.Context())
|
||||
reqCopy.Header.Set("User-Agent", getRandomUserAgent())
|
||||
reqCopy.Header.Set("User-Agent", userAgentForURL(reqCopy.URL))
|
||||
|
||||
return cloudflareBypassClient.Do(reqCopy)
|
||||
}
|
||||
|
||||
@@ -24,13 +24,18 @@ type LibraryScanResult struct {
|
||||
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
TrackNumber int `json:"trackNumber,omitempty"`
|
||||
TotalTracks int `json:"totalTracks,omitempty"`
|
||||
DiscNumber int `json:"discNumber,omitempty"`
|
||||
TotalDiscs int `json:"totalDiscs,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||
BitDepth int `json:"bitDepth,omitempty"`
|
||||
SampleRate int `json:"sampleRate,omitempty"`
|
||||
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Copyright string `json:"copyright,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
MetadataFromFilename bool `json:"metadataFromFilename,omitempty"`
|
||||
}
|
||||
@@ -365,9 +370,14 @@ func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint st
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
result.ReleaseDate = metadata.Date
|
||||
result.Genre = metadata.Genre
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
|
||||
quality, err := GetAudioQuality(filePath)
|
||||
if err == nil {
|
||||
@@ -397,12 +407,17 @@ func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
result.ReleaseDate = metadata.Date
|
||||
if result.ReleaseDate == "" {
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.Genre = metadata.Genre
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
}
|
||||
|
||||
quality, err := GetM4AQuality(filePath)
|
||||
@@ -427,7 +442,9 @@ func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
result.AlbumName = metadata.Album
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
result.Genre = metadata.Genre
|
||||
if metadata.Date != "" {
|
||||
result.ReleaseDate = metadata.Date
|
||||
@@ -435,6 +452,9 @@ func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.ISRC = metadata.ISRC
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
|
||||
quality, err := GetMP3Quality(filePath)
|
||||
if err == nil {
|
||||
@@ -464,9 +484,14 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
result.Genre = metadata.Genre
|
||||
result.ReleaseDate = metadata.Date
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
|
||||
quality, err := GetOggQuality(filePath)
|
||||
if err == nil {
|
||||
@@ -501,13 +526,18 @@ func scanAPEFile(filePath string, result *LibraryScanResult, displayNameHint str
|
||||
result.AlbumArtist = metadata.AlbumArtist
|
||||
result.ISRC = metadata.ISRC
|
||||
result.TrackNumber = metadata.TrackNumber
|
||||
result.TotalTracks = metadata.TotalTracks
|
||||
result.DiscNumber = metadata.DiscNumber
|
||||
result.TotalDiscs = metadata.TotalDiscs
|
||||
result.Genre = metadata.Genre
|
||||
if metadata.Date != "" {
|
||||
result.ReleaseDate = metadata.Date
|
||||
} else {
|
||||
result.ReleaseDate = metadata.Year
|
||||
}
|
||||
result.Composer = metadata.Composer
|
||||
result.Label = metadata.Label
|
||||
result.Copyright = metadata.Copyright
|
||||
|
||||
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
|
||||
|
||||
|
||||
@@ -39,8 +39,34 @@ var DefaultLyricsProviders = []string{
|
||||
var (
|
||||
lyricsProvidersMu sync.RWMutex
|
||||
lyricsProviders []string // ordered list of enabled providers
|
||||
appVersionMu sync.RWMutex
|
||||
appVersion string
|
||||
)
|
||||
|
||||
func SetAppVersion(version string) {
|
||||
normalized := strings.TrimSpace(version)
|
||||
|
||||
appVersionMu.Lock()
|
||||
defer appVersionMu.Unlock()
|
||||
appVersion = normalized
|
||||
}
|
||||
|
||||
func GetAppVersion() string {
|
||||
appVersionMu.RLock()
|
||||
defer appVersionMu.RUnlock()
|
||||
return appVersion
|
||||
}
|
||||
|
||||
func appUserAgent() string {
|
||||
version := GetAppVersion()
|
||||
|
||||
if version == "" {
|
||||
return "SpotiFLAC-Mobile"
|
||||
}
|
||||
|
||||
return "SpotiFLAC-Mobile/" + version
|
||||
}
|
||||
|
||||
type LyricsFetchOptions struct {
|
||||
IncludeTranslationNetease bool `json:"include_translation_netease"`
|
||||
IncludeRomanizationNetease bool `json:"include_romanization_netease"`
|
||||
@@ -385,8 +411,8 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
|
||||
primaryArtist := normalizeArtistName(artistName)
|
||||
fetchOptions := GetLyricsFetchOptions()
|
||||
|
||||
extManager := GetExtensionManager()
|
||||
var extensionProviders []*ExtensionProviderWrapper
|
||||
extManager := getExtensionManager()
|
||||
var extensionProviders []*extensionProviderWrapper
|
||||
if extManager != nil {
|
||||
extensionProviders = extManager.GetLyricsProviders()
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
@@ -147,7 +147,8 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -72,7 +72,7 @@ func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, dura
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -70,7 +70,7 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error)
|
||||
for k, v := range neteaseHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -109,7 +109,7 @@ func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includ
|
||||
for k, v := range neteaseHeaders {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -54,7 +54,7 @@ func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, dura
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -110,6 +111,7 @@ type Metadata struct {
|
||||
TrackNumber int
|
||||
TotalTracks int
|
||||
DiscNumber int
|
||||
TotalDiscs int
|
||||
ISRC string
|
||||
Description string
|
||||
Lyrics string
|
||||
@@ -273,23 +275,23 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
||||
|
||||
trackNum := getComment(cmt, "TRACKNUMBER")
|
||||
if trackNum != "" {
|
||||
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(trackNum)
|
||||
}
|
||||
if metadata.TrackNumber == 0 {
|
||||
trackNum = getComment(cmt, "TRACK")
|
||||
if trackNum != "" {
|
||||
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
||||
metadata.TrackNumber, metadata.TotalTracks = parseIndexPair(trackNum)
|
||||
}
|
||||
}
|
||||
|
||||
discNum := getComment(cmt, "DISCNUMBER")
|
||||
if discNum != "" {
|
||||
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(discNum)
|
||||
}
|
||||
if metadata.DiscNumber == 0 {
|
||||
discNum = getComment(cmt, "DISC")
|
||||
if discNum != "" {
|
||||
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||
metadata.DiscNumber, metadata.TotalDiscs = parseIndexPair(discNum)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,26 +405,39 @@ func EditFlacFields(filePath string, fields map[string]string) error {
|
||||
removeCommentKey(cmt, "ALBUM_ARTIST")
|
||||
}
|
||||
|
||||
// Track/disc numbers: present + empty → clear; present + "0" → clear.
|
||||
if v, ok := fields["track_number"]; ok {
|
||||
trackNum := 0
|
||||
if v != "" {
|
||||
fmt.Sscanf(v, "%d", &trackNum)
|
||||
// Track/disc numbers: present + empty → clear; when only totals are edited,
|
||||
// preserve the current index number and rewrite the combined value.
|
||||
if _, ok := fields["track_number"]; ok || fields["track_total"] != "" || hasMapKey(fields, "track_total") {
|
||||
currentTrackNum, currentTotalTracks := parseIndexPair(getComment(cmt, "TRACKNUMBER"))
|
||||
if currentTrackNum == 0 && currentTotalTracks == 0 {
|
||||
currentTrackNum, currentTotalTracks = parseIndexPair(getComment(cmt, "TRACK"))
|
||||
}
|
||||
if trackNum > 0 {
|
||||
setOrClearComment(cmt, "TRACKNUMBER", strconv.Itoa(trackNum))
|
||||
if v, ok := fields["track_number"]; ok {
|
||||
currentTrackNum = parsePositiveInt(v)
|
||||
}
|
||||
if v, ok := fields["track_total"]; ok {
|
||||
currentTotalTracks = parsePositiveInt(v)
|
||||
}
|
||||
if currentTrackNum > 0 {
|
||||
setOrClearComment(cmt, "TRACKNUMBER", formatIndexValue(currentTrackNum, currentTotalTracks))
|
||||
} else {
|
||||
removeCommentKey(cmt, "TRACKNUMBER")
|
||||
}
|
||||
removeCommentKey(cmt, "TRACK") // alias
|
||||
}
|
||||
if v, ok := fields["disc_number"]; ok {
|
||||
discNum := 0
|
||||
if v != "" {
|
||||
fmt.Sscanf(v, "%d", &discNum)
|
||||
if _, ok := fields["disc_number"]; ok || fields["disc_total"] != "" || hasMapKey(fields, "disc_total") {
|
||||
currentDiscNum, currentTotalDiscs := parseIndexPair(getComment(cmt, "DISCNUMBER"))
|
||||
if currentDiscNum == 0 && currentTotalDiscs == 0 {
|
||||
currentDiscNum, currentTotalDiscs = parseIndexPair(getComment(cmt, "DISC"))
|
||||
}
|
||||
if discNum > 0 {
|
||||
setOrClearComment(cmt, "DISCNUMBER", strconv.Itoa(discNum))
|
||||
if v, ok := fields["disc_number"]; ok {
|
||||
currentDiscNum = parsePositiveInt(v)
|
||||
}
|
||||
if v, ok := fields["disc_total"]; ok {
|
||||
currentTotalDiscs = parsePositiveInt(v)
|
||||
}
|
||||
if currentDiscNum > 0 {
|
||||
setOrClearComment(cmt, "DISCNUMBER", formatIndexValue(currentDiscNum, currentTotalDiscs))
|
||||
} else {
|
||||
removeCommentKey(cmt, "DISCNUMBER")
|
||||
}
|
||||
@@ -478,15 +493,11 @@ func writeVorbisMetadata(cmt *flacvorbis.MetaDataBlockVorbisComment, metadata Me
|
||||
setComment(cmt, "DATE", metadata.Date)
|
||||
|
||||
if metadata.TrackNumber > 0 {
|
||||
if metadata.TotalTracks > 0 {
|
||||
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
||||
} else {
|
||||
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
||||
}
|
||||
setComment(cmt, "TRACKNUMBER", formatIndexValue(metadata.TrackNumber, metadata.TotalTracks))
|
||||
}
|
||||
|
||||
if metadata.DiscNumber > 0 {
|
||||
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||
setComment(cmt, "DISCNUMBER", formatIndexValue(metadata.DiscNumber, metadata.TotalDiscs))
|
||||
}
|
||||
|
||||
if metadata.ISRC != "" {
|
||||
@@ -953,9 +964,9 @@ func ReadM4ATags(filePath string) (*AudioMetadata, error) {
|
||||
case "\xa9lyr":
|
||||
metadata.Lyrics, _ = readM4ATextValue(f, header, fi.Size())
|
||||
case "trkn":
|
||||
metadata.TrackNumber, _ = readM4AIndexValue(f, header, fi.Size())
|
||||
metadata.TrackNumber, metadata.TotalTracks, _ = readM4AIndexPair(f, header, fi.Size())
|
||||
case "disk":
|
||||
metadata.DiscNumber, _ = readM4AIndexValue(f, header, fi.Size())
|
||||
metadata.DiscNumber, metadata.TotalDiscs, _ = readM4AIndexPair(f, header, fi.Size())
|
||||
case "----":
|
||||
name, value, freeformErr := readM4AFreeformValue(f, header, fi.Size())
|
||||
if freeformErr == nil {
|
||||
@@ -1150,6 +1161,41 @@ func readM4AIndexValue(f *os.File, parent atomHeader, fileSize int64) (int, erro
|
||||
return int(binary.BigEndian.Uint16(payload[2:4])), nil
|
||||
}
|
||||
|
||||
func readM4AIndexPair(f *os.File, parent atomHeader, fileSize int64) (int, int, error) {
|
||||
payload, err := readM4ADataPayload(f, parent, fileSize)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if len(payload) < 6 {
|
||||
return 0, 0, fmt.Errorf("index payload too short in %s", parent.typ)
|
||||
}
|
||||
return int(binary.BigEndian.Uint16(payload[2:4])), int(binary.BigEndian.Uint16(payload[4:6])), nil
|
||||
}
|
||||
|
||||
func parsePositiveInt(value string) int {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(value)
|
||||
return n
|
||||
}
|
||||
|
||||
func formatIndexValue(number, total int) string {
|
||||
if number <= 0 {
|
||||
return ""
|
||||
}
|
||||
if total > 0 {
|
||||
return fmt.Sprintf("%d/%d", number, total)
|
||||
}
|
||||
return strconv.Itoa(number)
|
||||
}
|
||||
|
||||
func hasMapKey(fields map[string]string, key string) bool {
|
||||
_, ok := fields[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string, string, error) {
|
||||
start := parent.offset + parent.headerSize
|
||||
end := parent.offset + parent.size
|
||||
@@ -1199,6 +1245,281 @@ func readM4AFreeformValue(f *os.File, parent atomHeader, fileSize int64) (string
|
||||
return nameValue, dataValue, nil
|
||||
}
|
||||
|
||||
type m4aMetadataPath struct {
|
||||
moov atomHeader
|
||||
udta *atomHeader
|
||||
meta atomHeader
|
||||
ilst atomHeader
|
||||
}
|
||||
|
||||
func findM4AMetadataPath(f *os.File, fileSize int64) (m4aMetadataPath, error) {
|
||||
moov, found, err := findAtomInRange(f, 0, fileSize, "moov", fileSize)
|
||||
if err != nil || !found {
|
||||
return m4aMetadataPath{}, fmt.Errorf("moov not found")
|
||||
}
|
||||
|
||||
moovBodyStart := moov.offset + moov.headerSize
|
||||
moovBodySize := moov.size - moov.headerSize
|
||||
|
||||
if udta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "udta", fileSize); ok {
|
||||
udtaBodyStart := udta.offset + udta.headerSize
|
||||
udtaBodySize := udta.size - udta.headerSize
|
||||
if meta, ok2, _ := findAtomInRange(f, udtaBodyStart, udtaBodySize, "meta", fileSize); ok2 {
|
||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
||||
metaBodySize := meta.size - meta.headerSize - 4
|
||||
if ilst, ok3, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok3 {
|
||||
udtaCopy := udta
|
||||
return m4aMetadataPath{
|
||||
moov: moov,
|
||||
udta: &udtaCopy,
|
||||
meta: meta,
|
||||
ilst: ilst,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if meta, ok, _ := findAtomInRange(f, moovBodyStart, moovBodySize, "meta", fileSize); ok {
|
||||
metaBodyStart := meta.offset + meta.headerSize + 4
|
||||
metaBodySize := meta.size - meta.headerSize - 4
|
||||
if ilst, ok2, _ := findAtomInRange(f, metaBodyStart, metaBodySize, "ilst", fileSize); ok2 {
|
||||
return m4aMetadataPath{
|
||||
moov: moov,
|
||||
meta: meta,
|
||||
ilst: ilst,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return m4aMetadataPath{}, fmt.Errorf("ilst not found (tried moov>udta>meta>ilst and moov>meta>ilst)")
|
||||
}
|
||||
|
||||
func buildM4AAtom(typ string, payload []byte) []byte {
|
||||
size := int64(8 + len(payload))
|
||||
buf := make([]byte, 8+len(payload))
|
||||
binary.BigEndian.PutUint32(buf[0:4], uint32(size))
|
||||
copy(buf[4:8], []byte(typ))
|
||||
copy(buf[8:], payload)
|
||||
return buf
|
||||
}
|
||||
|
||||
func buildM4AFreeformAtom(name, value string) []byte {
|
||||
meanPayload := append([]byte{0, 0, 0, 0}, []byte("com.apple.iTunes")...)
|
||||
namePayload := append([]byte{0, 0, 0, 0}, []byte(name)...)
|
||||
dataPayload := make([]byte, 8+len(value))
|
||||
binary.BigEndian.PutUint32(dataPayload[0:4], 1) // UTF-8 text
|
||||
copy(dataPayload[8:], []byte(value))
|
||||
|
||||
payload := append([]byte{}, buildM4AAtom("mean", meanPayload)...)
|
||||
payload = append(payload, buildM4AAtom("name", namePayload)...)
|
||||
payload = append(payload, buildM4AAtom("data", dataPayload)...)
|
||||
return buildM4AAtom("----", payload)
|
||||
}
|
||||
|
||||
func buildITunNORMTag(trackGain, trackPeak string) string {
|
||||
gainDb, ok := parseReplayGainDb(trackGain)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
peakLinear, ok := parseReplayGainPeak(trackPeak)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
clamp := func(v int64) int64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 65534 {
|
||||
return 65534
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
g1 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 1000.0)))
|
||||
g2 := clamp(int64(math.Round(math.Pow(10, gainDb/-10.0) * 2500.0)))
|
||||
peak := clamp(int64(math.Round(peakLinear * 32768.0)))
|
||||
values := []int64{g1, g1, g2, g2, 0, 0, peak, peak, 0, 0}
|
||||
parts := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
parts = append(parts, strings.ToUpper(fmt.Sprintf("%08x", value)))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func parseReplayGainDb(value string) (float64, bool) {
|
||||
match := regexp.MustCompile(`([+-]?\d+(?:\.\d+)?)`).FindStringSubmatch(strings.TrimSpace(value))
|
||||
if len(match) < 2 {
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := strconv.ParseFloat(match[1], 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func parseReplayGainPeak(value string) (float64, bool) {
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func collectM4AReplayGainFields(fields map[string]string) map[string]string {
|
||||
result := map[string]string{}
|
||||
if value := strings.TrimSpace(fields["replaygain_track_gain"]); value != "" {
|
||||
result["replaygain_track_gain"] = value
|
||||
}
|
||||
if value := strings.TrimSpace(fields["replaygain_track_peak"]); value != "" {
|
||||
result["replaygain_track_peak"] = value
|
||||
}
|
||||
if value := strings.TrimSpace(fields["replaygain_album_gain"]); value != "" {
|
||||
result["replaygain_album_gain"] = value
|
||||
}
|
||||
if value := strings.TrimSpace(fields["replaygain_album_peak"]); value != "" {
|
||||
result["replaygain_album_peak"] = value
|
||||
}
|
||||
|
||||
if norm := buildITunNORMTag(result["replaygain_track_gain"], result["replaygain_track_peak"]); norm != "" {
|
||||
result["iTunNORM"] = norm
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func writeAtomSize(buf []byte, header atomHeader, newSize int64) error {
|
||||
if newSize <= 0 {
|
||||
return fmt.Errorf("invalid size for %s", header.typ)
|
||||
}
|
||||
if header.headerSize == 16 {
|
||||
if int(header.offset)+16 > len(buf) {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], 1)
|
||||
binary.BigEndian.PutUint64(buf[header.offset+8:header.offset+16], uint64(newSize))
|
||||
return nil
|
||||
}
|
||||
if newSize > math.MaxUint32 {
|
||||
return fmt.Errorf("atom %s too large for 32-bit header", header.typ)
|
||||
}
|
||||
if int(header.offset)+8 > len(buf) {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
binary.BigEndian.PutUint32(buf[header.offset:header.offset+4], uint32(newSize))
|
||||
return nil
|
||||
}
|
||||
|
||||
func EditM4AReplayGain(filePath string, fields map[string]string) error {
|
||||
replayGainFields := collectM4AReplayGainFields(fields)
|
||||
if len(replayGainFields) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path, err := findM4AMetadataPath(f, info.Size())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bodyStart := path.ilst.offset + path.ilst.headerSize
|
||||
bodyEnd := path.ilst.offset + path.ilst.size
|
||||
newBody := make([]byte, 0, int(path.ilst.size))
|
||||
targets := map[string]struct{}{
|
||||
"REPLAYGAIN_TRACK_GAIN": {},
|
||||
"REPLAYGAIN_TRACK_PEAK": {},
|
||||
"REPLAYGAIN_ALBUM_GAIN": {},
|
||||
"REPLAYGAIN_ALBUM_PEAK": {},
|
||||
"ITUNNORM": {},
|
||||
}
|
||||
|
||||
for pos := bodyStart; pos+8 <= bodyEnd; {
|
||||
header, readErr := readAtomHeaderAt(f, pos, info.Size())
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if header.size == 0 {
|
||||
header.size = bodyEnd - pos
|
||||
}
|
||||
if header.size < header.headerSize {
|
||||
return fmt.Errorf("invalid atom size for %s", header.typ)
|
||||
}
|
||||
|
||||
keep := true
|
||||
if header.typ == "----" {
|
||||
name, _, freeformErr := readM4AFreeformValue(f, header, info.Size())
|
||||
if freeformErr == nil {
|
||||
if _, ok := targets[strings.ToUpper(strings.TrimSpace(name))]; ok {
|
||||
keep = false
|
||||
}
|
||||
}
|
||||
}
|
||||
if keep {
|
||||
newBody = append(newBody, data[pos:pos+header.size]...)
|
||||
}
|
||||
|
||||
pos += header.size
|
||||
}
|
||||
|
||||
order := []string{
|
||||
"replaygain_track_gain",
|
||||
"replaygain_track_peak",
|
||||
"replaygain_album_gain",
|
||||
"replaygain_album_peak",
|
||||
"iTunNORM",
|
||||
}
|
||||
for _, key := range order {
|
||||
value := strings.TrimSpace(replayGainFields[key])
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
name := key
|
||||
if key != "iTunNORM" {
|
||||
name = strings.ToLower(key)
|
||||
}
|
||||
newBody = append(newBody, buildM4AFreeformAtom(name, value)...)
|
||||
}
|
||||
|
||||
newIlst := buildM4AAtom("ilst", newBody)
|
||||
updated := append([]byte{}, data[:path.ilst.offset]...)
|
||||
updated = append(updated, newIlst...)
|
||||
updated = append(updated, data[path.ilst.offset+path.ilst.size:]...)
|
||||
|
||||
delta := int64(len(newIlst)) - path.ilst.size
|
||||
if err := writeAtomSize(updated, path.ilst, path.ilst.size+delta); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomSize(updated, path.meta, path.meta.size+delta); err != nil {
|
||||
return err
|
||||
}
|
||||
if path.udta != nil {
|
||||
if err := writeAtomSize(updated, *path.udta, path.udta.size+delta); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := writeAtomSize(updated, path.moov, path.moov.size+delta); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(filePath, updated, 0o644)
|
||||
}
|
||||
|
||||
func extractLyricsFromSidecarLRC(filePath string) (string, error) {
|
||||
ext := filepath.Ext(filePath)
|
||||
base := strings.TrimSuffix(filePath, ext)
|
||||
@@ -1378,16 +1699,82 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
|
||||
// [28:32] samplerate (16.16 fixed-point)
|
||||
sampleRate := int(buf[28])<<8 | int(buf[29])
|
||||
bitDepth := int(buf[22])<<8 | int(buf[23])
|
||||
if bitDepth <= 0 {
|
||||
bitDepth = 16
|
||||
if atomType == "alac" {
|
||||
bitDepth = 24
|
||||
|
||||
if atomType == "alac" {
|
||||
if alacBitDepth, alacSampleRate, ok := readALACSpecificConfig(f, sampleOffset, fileSize); ok {
|
||||
if alacBitDepth > 0 {
|
||||
bitDepth = alacBitDepth
|
||||
}
|
||||
if alacSampleRate > 0 {
|
||||
sampleRate = alacSampleRate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bitDepth <= 0 {
|
||||
bitDepth = 16
|
||||
}
|
||||
|
||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||
}
|
||||
|
||||
func readALACSpecificConfig(f *os.File, sampleOffset, fileSize int64) (int, int, bool) {
|
||||
if sampleOffset < 4 {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
sampleEntryHeader, err := readAtomHeaderAt(f, sampleOffset-4, fileSize)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
childStart := sampleOffset + 32
|
||||
childEnd := sampleEntryHeader.offset + sampleEntryHeader.size
|
||||
if childStart >= childEnd {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
configHeader, found, err := findAtomInRange(f, childStart, childEnd-childStart, "alac", fileSize)
|
||||
if err != nil || !found {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
payloadSize := configHeader.size - configHeader.headerSize
|
||||
if payloadSize <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
payload := make([]byte, payloadSize)
|
||||
if _, err := f.ReadAt(payload, configHeader.offset+configHeader.headerSize); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
return parseALACSpecificConfig(payload)
|
||||
}
|
||||
|
||||
func parseALACSpecificConfig(payload []byte) (int, int, bool) {
|
||||
if len(payload) < 24 {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
bitDepth := int(payload[5])
|
||||
sampleRate := int(binary.BigEndian.Uint32(payload[20:24]))
|
||||
if bitDepth > 0 && sampleRate > 0 {
|
||||
return bitDepth, sampleRate, true
|
||||
}
|
||||
|
||||
// Some encoders prepend 4 bytes before the ALACSpecificConfig payload.
|
||||
if len(payload) >= 28 {
|
||||
bitDepth = int(payload[9])
|
||||
sampleRate = int(binary.BigEndian.Uint32(payload[24:28]))
|
||||
if bitDepth > 0 && sampleRate > 0 {
|
||||
return bitDepth, sampleRate, true
|
||||
}
|
||||
}
|
||||
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
type atomHeader struct {
|
||||
offset int64
|
||||
size int64
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseALACSpecificConfigStandardPayload(t *testing.T) {
|
||||
payload := make([]byte, 24)
|
||||
payload[5] = 24
|
||||
payload[20] = 0x00
|
||||
payload[21] = 0x00
|
||||
payload[22] = 0xac
|
||||
payload[23] = 0x44
|
||||
|
||||
bitDepth, sampleRate, ok := parseALACSpecificConfig(payload)
|
||||
if !ok {
|
||||
t.Fatal("expected standard ALAC payload to parse")
|
||||
}
|
||||
if bitDepth != 24 {
|
||||
t.Fatalf("bitDepth = %d, want 24", bitDepth)
|
||||
}
|
||||
if sampleRate != 44100 {
|
||||
t.Fatalf("sampleRate = %d, want 44100", sampleRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseALACSpecificConfigPayloadWithLeadingFourBytes(t *testing.T) {
|
||||
payload := make([]byte, 28)
|
||||
payload[9] = 16
|
||||
payload[24] = 0x00
|
||||
payload[25] = 0x00
|
||||
payload[26] = 0xbb
|
||||
payload[27] = 0x80
|
||||
|
||||
bitDepth, sampleRate, ok := parseALACSpecificConfig(payload)
|
||||
if !ok {
|
||||
t.Fatal("expected offset ALAC payload to parse")
|
||||
}
|
||||
if bitDepth != 16 {
|
||||
t.Fatalf("bitDepth = %d, want 16", bitDepth)
|
||||
}
|
||||
if sampleRate != 48000 {
|
||||
t.Fatalf("sampleRate = %d, want 48000", sampleRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseALACSpecificConfigRejectsShortPayload(t *testing.T) {
|
||||
if _, _, ok := parseALACSpecificConfig(make([]byte, 12)); ok {
|
||||
t.Fatal("expected short ALAC payload to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,13 @@ type TrackMetadata struct {
|
||||
TrackNumber int `json:"track_number"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumID string `json:"album_id,omitempty"`
|
||||
ArtistID string `json:"artist_id,omitempty"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
}
|
||||
|
||||
type AlbumTrackMetadata struct {
|
||||
@@ -42,11 +44,13 @@ type AlbumTrackMetadata struct {
|
||||
TrackNumber int `json:"track_number"`
|
||||
TotalTracks int `json:"total_tracks,omitempty"`
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
TotalDiscs int `json:"total_discs,omitempty"`
|
||||
ExternalURL string `json:"external_urls"`
|
||||
ISRC string `json:"isrc"`
|
||||
AlbumID string `json:"album_id,omitempty"`
|
||||
AlbumURL string `json:"album_url,omitempty"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
}
|
||||
|
||||
type AlbumInfoMetadata struct {
|
||||
|
||||
@@ -55,6 +55,7 @@ const (
|
||||
qobuzTrackPlayBaseURL = "https://play.qobuz.com/track/"
|
||||
qobuzStoreBaseURL = "https://www.qobuz.com/us-en"
|
||||
qobuzDownloadAPIURL = "https://dl.musicdl.me/qobuz/download"
|
||||
qobuzZarzDownloadAPIURL = "https://api.zarz.moe/dl/qbz"
|
||||
qobuzDabMusicAPIURL = "https://dabmusic.xyz/api/stream?trackId="
|
||||
qobuzDeebAPIURL = "https://dab.yeet.su/api/stream?trackId="
|
||||
qobuzAfkarAPIURL = "https://qbz.afkarxyz.qzz.io/api/track/"
|
||||
@@ -105,6 +106,10 @@ type QobuzTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"performer"`
|
||||
Composer struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"composer"`
|
||||
}
|
||||
|
||||
type qobuzImageSet struct {
|
||||
@@ -349,6 +354,7 @@ func qobuzTrackToTrackMetadata(track *QobuzTrack) TrackMetadata {
|
||||
AlbumID: qobuzPrefixedID(track.Album.ID),
|
||||
ArtistID: qobuzTrackArtistID(track),
|
||||
AlbumType: qobuzTrackAlbumType(track),
|
||||
Composer: strings.TrimSpace(track.Composer.Name),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +379,7 @@ func qobuzTrackToAlbumTrackMetadata(track *QobuzTrack) AlbumTrackMetadata {
|
||||
AlbumID: qobuzPrefixedID(track.Album.ID),
|
||||
AlbumURL: fmt.Sprintf("https://play.qobuz.com/album/%s", strings.TrimSpace(track.Album.ID)),
|
||||
AlbumType: qobuzTrackAlbumType(track),
|
||||
Composer: strings.TrimSpace(track.Composer.Name),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1030,6 +1037,7 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
||||
}
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
|
||||
totalDiscs := 0
|
||||
for i := range album.Tracks.Items {
|
||||
track := &album.Tracks.Items[i]
|
||||
track.Album.ID = album.ID
|
||||
@@ -1041,8 +1049,14 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
||||
Large: album.Image.Large,
|
||||
}
|
||||
track.Album.TracksCount = album.TracksCount
|
||||
if track.MediaNumber > totalDiscs {
|
||||
totalDiscs = track.MediaNumber
|
||||
}
|
||||
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track))
|
||||
}
|
||||
for i := range tracks {
|
||||
tracks[i].TotalDiscs = totalDiscs
|
||||
}
|
||||
|
||||
return &AlbumResponsePayload{
|
||||
AlbumInfo: qobuzAlbumToAlbumInfo(album),
|
||||
@@ -1126,6 +1140,7 @@ func (q *QobuzDownloader) GetArtistMetadata(resourceID string) (*ArtistResponseP
|
||||
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
||||
return []string{
|
||||
qobuzDownloadAPIURL,
|
||||
qobuzZarzDownloadAPIURL,
|
||||
qobuzDabMusicAPIURL,
|
||||
qobuzDeebAPIURL,
|
||||
qobuzAfkarAPIURL,
|
||||
@@ -1147,6 +1162,7 @@ const (
|
||||
func (q *QobuzDownloader) GetAvailableProviders() []qobuzAPIProvider {
|
||||
return []qobuzAPIProvider{
|
||||
{Name: "musicdl", URL: qobuzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
|
||||
{Name: "zarz", URL: qobuzZarzDownloadAPIURL, Kind: qobuzAPIKindMusicDL},
|
||||
{Name: "dabmusic", URL: qobuzDabMusicAPIURL, Kind: qobuzAPIKindStandard},
|
||||
{Name: "deeb", URL: qobuzDeebAPIURL, Kind: qobuzAPIKindStandard},
|
||||
{Name: "qbz", URL: qobuzAfkarAPIURL, Kind: qobuzAPIKindStandard},
|
||||
@@ -2639,17 +2655,8 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds)
|
||||
if track == nil {
|
||||
GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName)
|
||||
track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec)
|
||||
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search", false) {
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
|
||||
if track == nil {
|
||||
errMsg := "could not find matching track on Qobuz (artist/duration mismatch)"
|
||||
errMsg := "could not find matching track on Qobuz without identifier match"
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
@@ -2793,10 +2800,12 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
TrackNumber: actualTrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: req.DiscNumber,
|
||||
TotalDiscs: req.TotalDiscs,
|
||||
ISRC: track.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
Composer: req.Composer,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
|
||||
@@ -241,12 +241,13 @@ func TestExtractQobuzAlbumIDsFromArtistHTML(t *testing.T) {
|
||||
|
||||
func TestQobuzAvailableProviders(t *testing.T) {
|
||||
providers := NewQobuzDownloader().GetAvailableProviders()
|
||||
if len(providers) != 5 {
|
||||
t.Fatalf("expected 5 Qobuz providers, got %d", len(providers))
|
||||
if len(providers) != 6 {
|
||||
t.Fatalf("expected 6 Qobuz providers, got %d", len(providers))
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
"musicdl": qobuzAPIKindMusicDL,
|
||||
"zarz": qobuzAPIKindMusicDL,
|
||||
"dabmusic": qobuzAPIKindStandard,
|
||||
"deeb": qobuzAPIKindStandard,
|
||||
"qbz": qobuzAPIKindStandard,
|
||||
@@ -428,11 +429,9 @@ func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) {
|
||||
t.Fatal("ISRC fallback should not run without an ISRC")
|
||||
return nil, nil
|
||||
}
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||
if trackName != "Taste Back" || artistName != "Harry Styles" || expectedDurationSec != 181 {
|
||||
t.Fatalf("unexpected metadata fallback arguments: %q / %q / %d", trackName, artistName, expectedDurationSec)
|
||||
}
|
||||
return testQobuzTrack(444, "Taste Back", "Harry Styles", 181), nil
|
||||
qobuzSearchTrackByMetadataWithDurationFunc = func(_ *QobuzDownloader, _, _ string, _ int) (*QobuzTrack, error) {
|
||||
t.Fatal("metadata fallback should not run")
|
||||
return nil, nil
|
||||
}
|
||||
songLinkCheckTrackAvailabilityFunc = func(_ *SongLinkClient, _, _ string) (*TrackAvailability, error) {
|
||||
t.Fatal("SongLink should not run when Odesli QobuzID is provided")
|
||||
@@ -447,11 +446,11 @@ func TestResolveQobuzTrackForRequestRejectsOdesliMismatch(t *testing.T) {
|
||||
}
|
||||
|
||||
track, err := resolveQobuzTrackForRequest(req, &QobuzDownloader{}, "Test")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got track %+v", track)
|
||||
}
|
||||
if track == nil || track.ID != 444 || track.Title != "Taste Back" {
|
||||
t.Fatalf("unexpected resolved track: %+v", track)
|
||||
if track != nil {
|
||||
t.Fatalf("expected nil track, got %+v", track)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,3 +517,37 @@ func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
|
||||
t.Fatal("expected SongLink Qobuz source to bypass artist/title verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQobuzTrackMetadataIncludesComposer(t *testing.T) {
|
||||
track := &QobuzTrack{
|
||||
ID: 40681594,
|
||||
Title: "Sign of the Times",
|
||||
ISRC: "USSM11703595",
|
||||
Duration: 340,
|
||||
TrackNumber: 1,
|
||||
MediaNumber: 1,
|
||||
}
|
||||
track.Performer.ID = 729886
|
||||
track.Performer.Name = "Harry Styles"
|
||||
track.Composer.ID = 729886
|
||||
track.Composer.Name = "Harry Styles"
|
||||
track.Album.ID = "0886446451985"
|
||||
track.Album.Title = "Harry Styles"
|
||||
track.Album.ReleaseDate = "2017-05-12"
|
||||
track.Album.TracksCount = 10
|
||||
track.Album.ReleaseType = "album"
|
||||
track.Album.ProductType = "album"
|
||||
track.Album.Artist.ID = 729886
|
||||
track.Album.Artist.Name = "Harry Styles"
|
||||
track.Album.Artists = []qobuzArtistRef{{ID: 729886, Name: "Harry Styles"}}
|
||||
|
||||
trackMeta := qobuzTrackToTrackMetadata(track)
|
||||
if trackMeta.Composer != "Harry Styles" {
|
||||
t.Fatalf("track composer = %q", trackMeta.Composer)
|
||||
}
|
||||
|
||||
albumTrackMeta := qobuzTrackToAlbumTrackMetadata(track)
|
||||
if albumTrackMeta.Composer != "Harry Styles" {
|
||||
t.Fatalf("album track composer = %q", albumTrackMeta.Composer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
|
||||
return nil, fmt.Errorf("failed to create resolve request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", userAgentForURL(req.URL))
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
@@ -164,9 +165,9 @@ func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPl
|
||||
}
|
||||
|
||||
var resolveResp struct {
|
||||
Success bool `json:"success"`
|
||||
ISRC string `json:"isrc"`
|
||||
SongUrls map[string]json.RawMessage `json:"songUrls"`
|
||||
Success bool `json:"success"`
|
||||
ISRC string `json:"isrc"`
|
||||
SongUrls map[string]json.RawMessage `json:"songUrls"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resolveResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode resolve response: %w", err)
|
||||
|
||||
@@ -51,6 +51,7 @@ type TidalTrack struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ISRC string `json:"isrc"`
|
||||
Copyright string `json:"copyright"`
|
||||
AudioQuality string `json:"audioQuality"`
|
||||
TrackNumber int `json:"trackNumber"`
|
||||
VolumeNumber int `json:"volumeNumber"`
|
||||
@@ -135,6 +136,7 @@ type tidalPublicAlbum struct {
|
||||
Type string `json:"type"`
|
||||
Cover string `json:"cover"`
|
||||
ReleaseDate string `json:"releaseDate"`
|
||||
Copyright string `json:"copyright"`
|
||||
URL string `json:"url"`
|
||||
NumberOfTracks int `json:"numberOfTracks"`
|
||||
Explicit bool `json:"explicit"`
|
||||
@@ -306,6 +308,29 @@ func tidalTrackArtistsDisplay(track *TidalTrack) string {
|
||||
return strings.TrimSpace(track.Artist.Name)
|
||||
}
|
||||
|
||||
func tidalTrackAlbumArtistDisplay(track *TidalTrack) string {
|
||||
if track == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(track.Artists) > 0 {
|
||||
names := make([]string, 0, len(track.Artists))
|
||||
for _, artist := range track.Artists {
|
||||
if strings.ToUpper(strings.TrimSpace(artist.Type)) != "MAIN" {
|
||||
continue
|
||||
}
|
||||
if trimmed := strings.TrimSpace(artist.Name); trimmed != "" {
|
||||
names = append(names, trimmed)
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(track.Artist.Name)
|
||||
}
|
||||
|
||||
func tidalAlbumArtistsDisplay(album *tidalPublicAlbum) string {
|
||||
if album == nil {
|
||||
return ""
|
||||
@@ -354,7 +379,7 @@ func tidalTrackToTrackMetadata(track *TidalTrack) TrackMetadata {
|
||||
Artists: tidalTrackArtistsDisplay(track),
|
||||
Name: strings.TrimSpace(track.Title),
|
||||
AlbumName: strings.TrimSpace(track.Album.Title),
|
||||
AlbumArtist: strings.TrimSpace(track.Artist.Name),
|
||||
AlbumArtist: tidalTrackAlbumArtistDisplay(track),
|
||||
DurationMS: track.Duration * 1000,
|
||||
Images: tidalImageURL(track.Album.Cover, "1280x1280"),
|
||||
ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate),
|
||||
@@ -377,7 +402,7 @@ func tidalTrackToAlbumTrackMetadata(track *TidalTrack) AlbumTrackMetadata {
|
||||
Artists: tidalTrackArtistsDisplay(track),
|
||||
Name: strings.TrimSpace(track.Title),
|
||||
AlbumName: strings.TrimSpace(track.Album.Title),
|
||||
AlbumArtist: strings.TrimSpace(track.Artist.Name),
|
||||
AlbumArtist: tidalTrackAlbumArtistDisplay(track),
|
||||
DurationMS: track.Duration * 1000,
|
||||
Images: tidalImageURL(track.Album.Cover, "1280x1280"),
|
||||
ReleaseDate: strings.TrimSpace(track.Album.ReleaseDate),
|
||||
@@ -407,6 +432,7 @@ func tidalAlbumToAlbumInfo(album *tidalPublicAlbum) AlbumInfoMetadata {
|
||||
Artists: tidalAlbumArtistsDisplay(album),
|
||||
ArtistId: artistID,
|
||||
Images: tidalImageURL(album.Cover, "1280x1280"),
|
||||
Copyright: strings.TrimSpace(album.Copyright),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -688,6 +714,10 @@ func findTidalArtistPageModule(page *tidalPublicArtistPage, moduleType string) *
|
||||
|
||||
func (t *TidalDownloader) GetAvailableAPIs() []string {
|
||||
return []string{
|
||||
"https://eu-central.monochrome.tf",
|
||||
"https://us-west.monochrome.tf",
|
||||
"https://api.monochrome.tf",
|
||||
"https://monochrome-api.samidy.com",
|
||||
"https://tidal-api.binimum.org",
|
||||
"https://tidal.kinoplus.online",
|
||||
"https://triton.squid.wtf",
|
||||
@@ -1012,6 +1042,7 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
||||
}
|
||||
|
||||
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
|
||||
totalDiscs := 0
|
||||
for _, item := range itemsModule.PagedList.Items {
|
||||
track := item.Item
|
||||
track.Album.ID = headerModule.Album.ID
|
||||
@@ -1019,8 +1050,14 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
|
||||
track.Album.Cover = headerModule.Album.Cover
|
||||
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
|
||||
track.Album.URL = headerModule.Album.URL
|
||||
if track.VolumeNumber > totalDiscs {
|
||||
totalDiscs = track.VolumeNumber
|
||||
}
|
||||
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
|
||||
}
|
||||
for i := range tracks {
|
||||
tracks[i].TotalDiscs = totalDiscs
|
||||
}
|
||||
|
||||
return &AlbumResponsePayload{
|
||||
AlbumInfo: tidalAlbumToAlbumInfo(&headerModule.Album),
|
||||
@@ -1729,6 +1766,7 @@ type TidalDownloadResult struct {
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
Copyright string
|
||||
LyricsLRC string // LRC content for embedding in converted files
|
||||
}
|
||||
|
||||
@@ -2042,18 +2080,6 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
|
||||
}
|
||||
}
|
||||
|
||||
if !gotTidalID && req.ISRC != "" {
|
||||
GoLog("[%s] Trying direct Tidal ISRC search: %s\n", logPrefix, req.ISRC)
|
||||
directTrack, directErr := downloader.SearchTrackByISRC(req.ISRC)
|
||||
if directErr == nil && directTrack != nil && directTrack.ID > 0 {
|
||||
trackID = directTrack.ID
|
||||
gotTidalID = true
|
||||
GoLog("[%s] Got Tidal ID %d from direct ISRC search\n", logPrefix, trackID)
|
||||
} else if directErr != nil {
|
||||
GoLog("[%s] Direct Tidal ISRC search failed: %v\n", logPrefix, directErr)
|
||||
}
|
||||
}
|
||||
|
||||
if !gotTidalID && req.ISRC != "" && req.TrackName != "" && req.ArtistName != "" {
|
||||
GoLog("[%s] Trying Tidal public metadata search with ISRC\n", logPrefix)
|
||||
searchTrack, searchErr := downloader.SearchTrackByMetadataWithISRC(
|
||||
@@ -2349,6 +2375,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
if actualDiscNumber == 0 {
|
||||
actualDiscNumber = track.VolumeNumber
|
||||
}
|
||||
copyright := strings.TrimSpace(req.Copyright)
|
||||
if copyright == "" {
|
||||
copyright = strings.TrimSpace(track.Copyright)
|
||||
}
|
||||
|
||||
metadata := Metadata{
|
||||
Title: req.TrackName,
|
||||
@@ -2360,10 +2390,12 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
TrackNumber: actualTrackNumber,
|
||||
TotalTracks: req.TotalTracks,
|
||||
DiscNumber: actualDiscNumber,
|
||||
TotalDiscs: req.TotalDiscs,
|
||||
ISRC: track.ISRC,
|
||||
Genre: req.Genre,
|
||||
Label: req.Label,
|
||||
Copyright: req.Copyright,
|
||||
Copyright: copyright,
|
||||
Composer: req.Composer,
|
||||
}
|
||||
|
||||
var coverData []byte
|
||||
@@ -2473,6 +2505,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
TrackNumber: resultTrackNumber,
|
||||
DiscNumber: resultDiscNumber,
|
||||
ISRC: track.ISRC,
|
||||
Copyright: copyright,
|
||||
LyricsLRC: lyricsLRC,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 17 KiB |
@@ -22,6 +22,9 @@ import Gobackend // Import Go framework
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
|
||||
GobackendSetAppVersion(version)
|
||||
}
|
||||
|
||||
let controller = window?.rootViewController as! FlutterViewController
|
||||
let channel = FlutterMethodChannel(
|
||||
@@ -66,9 +69,59 @@ import Gobackend // Import Go framework
|
||||
)
|
||||
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
if let url = launchOptions?[.url] as? URL {
|
||||
_ = handleExtensionOAuthRedirect(url: url)
|
||||
}
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
/// PKCE OAuth return URL: spotiflac://callback?code=...&state=<extension_id>
|
||||
@discardableResult
|
||||
private func handleExtensionOAuthRedirect(url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased(), scheme == "spotiflac" else { return false }
|
||||
let host = (url.host ?? "").lowercased()
|
||||
let path = url.path.lowercased()
|
||||
let ok =
|
||||
host == "callback" || host == "spotify-callback" || path.contains("callback")
|
||||
guard ok else { return false }
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return false
|
||||
}
|
||||
let q = components.queryItems ?? []
|
||||
let code =
|
||||
q.first { $0.name == "code" }?.value?.trimmingCharacters(
|
||||
in: .whitespacesAndNewlines) ?? ""
|
||||
let state =
|
||||
q.first { $0.name == "state" }?.value?.trimmingCharacters(
|
||||
in: .whitespacesAndNewlines) ?? ""
|
||||
if code.isEmpty { return false }
|
||||
if state.isEmpty {
|
||||
NSLog("SpotiFLAC: Extension OAuth redirect missing state (extension id)")
|
||||
return false
|
||||
}
|
||||
streamQueue.async {
|
||||
var err: NSError?
|
||||
GobackendSetExtensionAuthCodeByID(state, code)
|
||||
_ = GobackendInvokeExtensionActionJSON(state, "completeSpotifyLogin", &err)
|
||||
if let err = err {
|
||||
NSLog(
|
||||
"SpotiFLAC: Extension OAuth complete failed: \(err.localizedDescription)")
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override func application(
|
||||
_ app: UIApplication,
|
||||
open url: URL,
|
||||
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
|
||||
) -> Bool {
|
||||
if handleExtensionOAuthRedirect(url: url) {
|
||||
return true
|
||||
}
|
||||
return super.application(app, open: url, options: options)
|
||||
}
|
||||
|
||||
deinit {
|
||||
stopDownloadProgressStream()
|
||||
stopLibraryScanProgressStream()
|
||||
@@ -371,16 +424,6 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchDeezerAll":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
let trackLimit = args["track_limit"] as? Int ?? 15
|
||||
let artistLimit = args["artist_limit"] as? Int ?? 3
|
||||
let filter = args["filter"] as? String ?? ""
|
||||
let response = GobackendSearchDeezerAll(query, Int(trackLimit), Int(artistLimit), filter, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchTidalAll":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as! String
|
||||
@@ -607,6 +650,13 @@ import Gobackend // Import Go framework
|
||||
let response = GobackendGetProviderPriorityJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "setDownloadFallbackExtensionIds":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionIdsJson = args["extension_ids"] as? String ?? ""
|
||||
GobackendSetExtensionFallbackProviderIDsJSON(extensionIdsJson, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "setMetadataProviderPriority":
|
||||
let args = call.arguments as! [String: Any]
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '4.2.0';
|
||||
static const String buildNumber = '121';
|
||||
static const String version = '4.3.1';
|
||||
static const String buildNumber = '126';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
/// Shows "Internal" in debug builds, actual version in release.
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'app_localizations_nl.dart';
|
||||
import 'app_localizations_pt.dart';
|
||||
import 'app_localizations_ru.dart';
|
||||
import 'app_localizations_tr.dart';
|
||||
import 'app_localizations_uk.dart';
|
||||
import 'app_localizations_zh.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
@@ -119,6 +120,7 @@ abstract class AppLocalizations {
|
||||
Locale('pt', 'PT'),
|
||||
Locale('ru'),
|
||||
Locale('tr'),
|
||||
Locale('uk'),
|
||||
Locale('zh'),
|
||||
Locale('zh', 'CN'),
|
||||
Locale('zh', 'TW'),
|
||||
@@ -352,6 +354,18 @@ abstract class AppLocalizations {
|
||||
/// **'Using extension: {extensionName}'**
|
||||
String optionsUsingExtension(String extensionName);
|
||||
|
||||
/// Title for the preferred default search tab setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Default Search Tab'**
|
||||
String get optionsDefaultSearchTab;
|
||||
|
||||
/// Subtitle for the preferred default search tab setting
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose which tab opens first for new search results.'**
|
||||
String get optionsDefaultSearchTabSubtitle;
|
||||
|
||||
/// Hint to switch back to built-in providers
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -718,6 +732,12 @@ abstract class AppLocalizations {
|
||||
/// **'PC source code'**
|
||||
String get aboutPCSource;
|
||||
|
||||
/// Link to Keep Android Open campaign website
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Keep Android Open'**
|
||||
String get aboutKeepAndroidOpen;
|
||||
|
||||
/// Link to report bugs
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1738,6 +1758,24 @@ abstract class AppLocalizations {
|
||||
/// **'If a track is not available on the first provider, the app will automatically try the next one.'**
|
||||
String get providerPriorityInfo;
|
||||
|
||||
/// Section title for choosing which download extensions can be used as fallback providers
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Extension Fallback'**
|
||||
String get providerPriorityFallbackExtensionsTitle;
|
||||
|
||||
/// Section description for extension fallback selection
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.'**
|
||||
String get providerPriorityFallbackExtensionsDescription;
|
||||
|
||||
/// Hint below the extension fallback selection list
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Only enabled extensions with download-provider capability are listed here.'**
|
||||
String get providerPriorityFallbackExtensionsHint;
|
||||
|
||||
/// Label for built-in providers (Tidal/Qobuz)
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2644,6 +2682,18 @@ abstract class AppLocalizations {
|
||||
/// **'Set download service order'**
|
||||
String get extensionsDownloadPrioritySubtitle;
|
||||
|
||||
/// Setting and page title for choosing which download extensions can be used during fallback
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Fallback Extensions'**
|
||||
String get extensionsFallbackTitle;
|
||||
|
||||
/// Subtitle for download fallback extensions menu
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Choose which installed download extensions can be used as fallback'**
|
||||
String get extensionsFallbackSubtitle;
|
||||
|
||||
/// Empty state - no download providers
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -5809,6 +5859,7 @@ class _AppLocalizationsDelegate
|
||||
'pt',
|
||||
'ru',
|
||||
'tr',
|
||||
'uk',
|
||||
'zh',
|
||||
].contains(locale.languageCode);
|
||||
|
||||
@@ -5873,6 +5924,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
|
||||
return AppLocalizationsRu();
|
||||
case 'tr':
|
||||
return AppLocalizationsTr();
|
||||
case 'uk':
|
||||
return AppLocalizationsUk();
|
||||
case 'zh':
|
||||
return AppLocalizationsZh();
|
||||
}
|
||||
|
||||
@@ -21,13 +21,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get navSettings => 'Einstellungen';
|
||||
|
||||
@override
|
||||
String get navStore => 'Store';
|
||||
String get navStore => 'Repo';
|
||||
|
||||
@override
|
||||
String get homeTitle => 'Startseite';
|
||||
|
||||
@override
|
||||
String get homeSubtitle => 'Spotify-Link einfügen oder nach Namen suchen';
|
||||
String get homeSubtitle => 'Unterstützte URL einfügen oder nach Namen suchen';
|
||||
|
||||
@override
|
||||
String get homeSupports =>
|
||||
@@ -129,6 +129,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
return 'Erweiterung verwenden: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tippe auf Deezer oder Spotify, um von der Erweiterung zurückzuwechseln';
|
||||
@@ -177,21 +184,21 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
String get optionsArtistTagMode => 'Künstler Tag-Modus';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeDescription =>
|
||||
'Choose how multiple artists are written into embedded tags.';
|
||||
'Wähle aus, wie mehrere Künstler in eingebetteten Tags geschrieben sind.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoined => 'Single joined value';
|
||||
String get optionsArtistTagModeJoined => 'Einzelne beigefügte Werte';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeJoinedSubtitle =>
|
||||
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
|
||||
'Einen Künstler wert wie \"Artist A, Artist B\" für maximale Spieler-Kompatibilität schreiben.';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
|
||||
String get optionsArtistTagModeSplitVorbis => 'Tags für FLAC/Opus aufteilen';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagModeSplitVorbisSubtitle =>
|
||||
@@ -213,11 +220,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
'Parallele Downloads können Ratenlimitierung auslösen';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Erweiterungs-Store';
|
||||
String get optionsExtensionStore => 'Erweiterungs-Repo';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStoreSubtitle =>
|
||||
'Store-Tab in Navigation anzeigen';
|
||||
'Repo-Tab in der Navigation anzeigen';
|
||||
|
||||
@override
|
||||
String get optionsCheckUpdates => 'Nach Updates suchen';
|
||||
@@ -296,7 +303,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get extensionsUninstall => 'Deinstallieren';
|
||||
|
||||
@override
|
||||
String get storeTitle => 'Erweiterungs-Store';
|
||||
String get storeTitle => 'Erweiterungs-Repo';
|
||||
|
||||
@override
|
||||
String get storeSearch => 'Erweiterungen suchen...';
|
||||
@@ -341,6 +348,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'PC Quellcode';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Problem melden';
|
||||
|
||||
@@ -576,7 +586,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get dialogImport => 'Importieren';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
String get dialogDownload => 'Herunterladen';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Verwerfen';
|
||||
@@ -809,37 +819,37 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get searchAlbums => 'Alben';
|
||||
|
||||
@override
|
||||
String get searchPlaylists => 'Playlisten';
|
||||
String get searchPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get searchSortTitle => 'Sort Results';
|
||||
String get searchSortTitle => 'Ergebnisse sortieren';
|
||||
|
||||
@override
|
||||
String get searchSortDefault => 'Default';
|
||||
String get searchSortDefault => 'Standard';
|
||||
|
||||
@override
|
||||
String get searchSortTitleAZ => 'Title (A-Z)';
|
||||
String get searchSortTitleAZ => 'Titel (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortTitleZA => 'Title (Z-A)';
|
||||
String get searchSortTitleZA => 'Titel (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistAZ => 'Artist (A-Z)';
|
||||
String get searchSortArtistAZ => 'Künstler (A-Z)';
|
||||
|
||||
@override
|
||||
String get searchSortArtistZA => 'Artist (Z-A)';
|
||||
String get searchSortArtistZA => 'Künstler (Z-A)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationShort => 'Duration (Shortest)';
|
||||
String get searchSortDurationShort => 'Dauer (kürzeste)';
|
||||
|
||||
@override
|
||||
String get searchSortDurationLong => 'Duration (Longest)';
|
||||
String get searchSortDurationLong => 'Dauer (längste)';
|
||||
|
||||
@override
|
||||
String get searchSortDateOldest => 'Release Date (Oldest)';
|
||||
String get searchSortDateOldest => 'Veröffentlichungsdatum (älteste)';
|
||||
|
||||
@override
|
||||
String get searchSortDateNewest => 'Release Date (Newest)';
|
||||
String get searchSortDateNewest => 'Veröffentlichungsdatum (Neueste)';
|
||||
|
||||
@override
|
||||
String get tooltipPlay => 'Abspielen';
|
||||
@@ -940,6 +950,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'Wenn kein Titel bei dem ersten Anbieter nicht verfügbar ist, wird die App automatisch den nächsten versuchen.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Integriert';
|
||||
|
||||
@@ -1294,36 +1315,36 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get storeClearFilters => 'Filter entfernen';
|
||||
|
||||
@override
|
||||
String get storeAddRepoTitle => 'Add Extension Repository';
|
||||
String get storeAddRepoTitle => 'Erweiterungs-Repository hinzufügen';
|
||||
|
||||
@override
|
||||
String get storeAddRepoDescription =>
|
||||
'Enter a GitHub repository URL that contains a registry.json file to browse and install extensions.';
|
||||
'Gib eine GitHub Repository-URL ein, die eine Registry.json Datei enthält, um Erweiterungen zu durchsuchen und zu installieren.';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlLabel => 'Repository URL';
|
||||
String get storeRepoUrlLabel => 'Repository-URL';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlHint => 'https://github.com/user/repo';
|
||||
|
||||
@override
|
||||
String get storeRepoUrlHelper =>
|
||||
'e.g. https://github.com/user/extensions-repo';
|
||||
'z.B. https://github.com/user/extensions-repo';
|
||||
|
||||
@override
|
||||
String get storeAddRepoButton => 'Add Repository';
|
||||
String get storeAddRepoButton => 'Repository hinzufügen';
|
||||
|
||||
@override
|
||||
String get storeChangeRepoTooltip => 'Change repository';
|
||||
String get storeChangeRepoTooltip => 'Repository ändern';
|
||||
|
||||
@override
|
||||
String get storeRepoDialogTitle => 'Extension Repository';
|
||||
String get storeRepoDialogTitle => 'Erweiterungs-Repository';
|
||||
|
||||
@override
|
||||
String get storeRepoDialogCurrent => 'Current repository:';
|
||||
String get storeRepoDialogCurrent => 'Aktuelles Repository:';
|
||||
|
||||
@override
|
||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||
String get storeNewRepoUrlLabel => 'Neue Repository-URL';
|
||||
|
||||
@override
|
||||
String get storeLoadError => 'Failed to load repository';
|
||||
@@ -1335,7 +1356,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Standard (Deezer/Spotify)';
|
||||
String get extensionDefaultProvider => 'Standard (Deezer)';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Eingebaute Suche verwenden';
|
||||
@@ -1438,6 +1459,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get extensionsDownloadPrioritySubtitle =>
|
||||
'Download-Service-Reihenfolge festlegen';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'Keine Erweiterungen mit Download-Provider';
|
||||
@@ -1489,36 +1517,36 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get qualityHiResFlacMaxSubtitle => '24-Bit / bis 192kHz';
|
||||
|
||||
@override
|
||||
String get downloadLossy320 => 'Lossy 320kbps';
|
||||
String get downloadLossy320 => 'Verlustbehaftet 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyFormat => 'Lossy Format';
|
||||
String get downloadLossyFormat => 'Verlustbehaftetes Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320Format => 'Lossy 320kbps Format';
|
||||
|
||||
@override
|
||||
String get downloadLossy320FormatDesc =>
|
||||
'Choose the output format for Tidal 320kbps lossy downloads. The original AAC stream will be converted to your selected format.';
|
||||
'Wähle das Ausgabeformat für Tidal 320kbps verlustbehaftete Downloads. Der ursprüngliche AAC Stream wird in das ausgewählte Format konvertiert.';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3 => 'MP3 320kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyMp3Subtitle => 'Best compatibility, ~10MB per track';
|
||||
String get downloadLossyMp3Subtitle =>
|
||||
'Beste Kompatibilität, ~10MB pro Titel';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256 => 'Opus 256kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus256Subtitle =>
|
||||
'Best quality Opus, ~8MB per track';
|
||||
String get downloadLossyOpus256Subtitle => 'Beste Qualität, ~8MB pro Titel';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128 => 'Opus 128kbps';
|
||||
|
||||
@override
|
||||
String get downloadLossyOpus128Subtitle => 'Smallest size, ~4MB per track';
|
||||
String get downloadLossyOpus128Subtitle => 'Kleinste Größe, ~4MB pro Track';
|
||||
|
||||
@override
|
||||
String get qualityNote =>
|
||||
@@ -1828,23 +1856,23 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
'Bei der Suche nach vorhandenen Titeln anzeigen';
|
||||
|
||||
@override
|
||||
String get libraryAutoScan => 'Auto Scan';
|
||||
String get libraryAutoScan => 'Auto-Scan';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanSubtitle =>
|
||||
'Automatically scan your library for new files';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOff => 'Off';
|
||||
String get libraryAutoScanOff => 'Aus';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanOnOpen => 'Every app open';
|
||||
String get libraryAutoScanOnOpen => 'Bei jeder App Öffnung';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanDaily => 'Daily';
|
||||
String get libraryAutoScanDaily => 'Täglich';
|
||||
|
||||
@override
|
||||
String get libraryAutoScanWeekly => 'Weekly';
|
||||
String get libraryAutoScanWeekly => 'Wöchentlich';
|
||||
|
||||
@override
|
||||
String get libraryActions => 'Aktionen';
|
||||
@@ -1901,8 +1929,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'files',
|
||||
one: 'file',
|
||||
other: '$count Datein',
|
||||
one: '1 Datei',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -1919,7 +1947,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get libraryScanning => 'Scannen...';
|
||||
|
||||
@override
|
||||
String get libraryScanFinalizing => 'Finalizing library...';
|
||||
String get libraryScanFinalizing => 'Bibliothek wird aktualisiert...';
|
||||
|
||||
@override
|
||||
String libraryScanProgress(String progress, int total) {
|
||||
@@ -1990,22 +2018,23 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get libraryFilterFormat => 'Format';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadata => 'Metadata';
|
||||
String get libraryFilterMetadata => 'Metadaten';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataComplete => 'Complete metadata';
|
||||
String get libraryFilterMetadataComplete => 'Komplette Metadaten';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
|
||||
String get libraryFilterMetadataMissingAny => 'Metadaten fehlen';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingYear => 'Missing year';
|
||||
String get libraryFilterMetadataMissingYear => 'Jahr fehlt';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingGenre => 'Missing genre';
|
||||
String get libraryFilterMetadataMissingGenre => 'Genre fehlt';
|
||||
|
||||
@override
|
||||
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
|
||||
String get libraryFilterMetadataMissingAlbumArtist =>
|
||||
'Fehlender Album-Künstler';
|
||||
|
||||
@override
|
||||
String get libraryFilterSort => 'Sortieren';
|
||||
@@ -2037,7 +2066,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'vor $count Minuten',
|
||||
one: 'vor $count Minute',
|
||||
one: 'vor 1 Minute',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -2048,7 +2077,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'vor $count Stunden',
|
||||
one: 'vor $count Stunde',
|
||||
one: 'vor 1 Stunde',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -2114,7 +2143,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Im Store Tab findest du nützliche Erweiterungen';
|
||||
'Browse the Repo tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
@@ -2368,11 +2397,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
'FFmpeg Metadaten-Einbettung fehlgeschlagen';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
String get queueFlacAction => 'Warteschlange FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
return 'Suche Online-Matches für ausgewählte Titel und Playlists für FLAC-Downloads.\n\nVorhandene Dateien werden weder geändert noch gelöscht.\n\nNur eindeutige Treffer werden automatisch zur Warteschlange hinzugefügt.\n\n$count ausgewählt';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2398,7 +2427,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get trackConvertFormat => 'Format konvertieren';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'In MP3 oder Opus konvertieren';
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Audio konvertieren';
|
||||
@@ -2426,7 +2456,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
return 'Konvertieren von $sourceFormat in $targetFormat? (kein Qualitätsverlust)\n\nDie Originaldatei wird nach der Konvertierung gelöscht.';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2506,7 +2536,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get collectionLoved => 'Lieblingssongs';
|
||||
|
||||
@override
|
||||
String get collectionPlaylists => 'Playlisten';
|
||||
String get collectionPlaylists => 'Playlists';
|
||||
|
||||
@override
|
||||
String get collectionPlaylist => 'Playlist';
|
||||
@@ -2693,10 +2723,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
other: 'Titel',
|
||||
one: 'Titel',
|
||||
);
|
||||
return 'Convert $count $_temp0 to $format? (Lossless — no quality loss)\n\nOriginal files will be deleted after conversion.';
|
||||
return 'Konvertiere $count $_temp0 in $format? (kein Qualitätsverlust)\n\nOriginaldateien werden nach der Konvertierung gelöscht.';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2723,24 +2753,24 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
'Künstler-Ordner nur für Titel-Künstler';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersTitle => 'Lyrics Providers';
|
||||
String get lyricsProvidersTitle => 'Lyrics-Anbieter';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersDescription =>
|
||||
'Enable, disable and reorder lyrics sources. Providers are tried top-to-bottom until lyrics are found.';
|
||||
'Lyrics aktivieren, deaktivieren und neu ordnen. Anbieter werden von oben nach unten ausprobiert, bis Lyrics gefunden werden.';
|
||||
|
||||
@override
|
||||
String get lyricsProvidersInfoText =>
|
||||
'Extension lyrics providers always run before built-in providers. At least one provider must remain enabled.';
|
||||
'Erweiterungsanbieter werden immer vor eingebauten ausgeführt. Mindestens ein Anbieter muss aktiviert bleiben.';
|
||||
|
||||
@override
|
||||
String lyricsProvidersEnabledSection(int count) {
|
||||
return 'Enabled ($count)';
|
||||
return '($count) aktiviert';
|
||||
}
|
||||
|
||||
@override
|
||||
String lyricsProvidersDisabledSection(int count) {
|
||||
return 'Disabled ($count)';
|
||||
return '($count) deaktiviert';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2759,52 +2789,53 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get lyricsProviderNeteaseDesc =>
|
||||
'NetEase Cloud Music (good for Asian songs)';
|
||||
'NetEase Cloud Music (gut für asiatische Lieder)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderMusixmatchDesc =>
|
||||
'Largest lyrics database (multi-language)';
|
||||
'Größte Lyrics-Datenbank (mehrsprachig)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderAppleMusicDesc =>
|
||||
'Word-by-word synced lyrics (via proxy)';
|
||||
'Wort-für-Wort-synchronisierte Lyrics (via Proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderQqMusicDesc =>
|
||||
'QQ Music (good for Chinese songs, via proxy)';
|
||||
'QQ Music (gut für chinesische Lieder, via Proxy)';
|
||||
|
||||
@override
|
||||
String get lyricsProviderExtensionDesc => 'Extension provider';
|
||||
String get lyricsProviderExtensionDesc => 'Erweiterungsanbieter';
|
||||
|
||||
@override
|
||||
String get safMigrationTitle => 'Storage Update Required';
|
||||
String get safMigrationTitle => 'Speicheraktualisierung erforderlich';
|
||||
|
||||
@override
|
||||
String get safMigrationMessage1 =>
|
||||
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. This fixes \"permission denied\" errors on Android 10+.';
|
||||
'SpotiFLAC verwendet jetzt Android Storage Access Framework (SAF) beim Herunterladen. Dies behebt Fehler bei Android 10+.';
|
||||
|
||||
@override
|
||||
String get safMigrationMessage2 =>
|
||||
'Please select your download folder again to switch to the new storage system.';
|
||||
'Bitte wähle dein Download-Ordner erneut aus, um zum neuen System zu wechseln.';
|
||||
|
||||
@override
|
||||
String get safMigrationSuccess => 'Download folder updated to SAF mode';
|
||||
|
||||
@override
|
||||
String get settingsDonate => 'Donate';
|
||||
String get settingsDonate => 'Unterstützen';
|
||||
|
||||
@override
|
||||
String get settingsDonateSubtitle => 'Support SpotiFLAC-Mobile development';
|
||||
String get settingsDonateSubtitle =>
|
||||
'Unterstütze die SpotiFLAC-Mobile Entwickler';
|
||||
|
||||
@override
|
||||
String get tooltipLoveAll => 'Love All';
|
||||
String get tooltipLoveAll => 'Alle lieben';
|
||||
|
||||
@override
|
||||
String get tooltipAddToPlaylist => 'Add to Playlist';
|
||||
String get tooltipAddToPlaylist => 'Zur Wiedergabeliste hinzufügen';
|
||||
|
||||
@override
|
||||
String snackbarRemovedTracksFromLoved(int count) {
|
||||
return 'Removed $count tracks from Loved';
|
||||
return '$count Titel von geliebt entfernt';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2813,7 +2844,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get dialogDownloadAllTitle => 'Download All';
|
||||
String get dialogDownloadAllTitle => 'Alle Herunterladen';
|
||||
|
||||
@override
|
||||
String dialogDownloadAllMessage(int count) {
|
||||
@@ -2824,7 +2855,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get homeSkipAlreadyDownloaded => 'Skip already downloaded songs';
|
||||
|
||||
@override
|
||||
String get homeGoToAlbum => 'Go to Album';
|
||||
String get homeGoToAlbum => 'Zum Album gehen';
|
||||
|
||||
@override
|
||||
String get homeAlbumInfoUnavailable => 'Album info not available';
|
||||
@@ -2843,7 +2874,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String snackbarError(String error) {
|
||||
return 'Error: $error';
|
||||
return 'Fehler: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2863,7 +2894,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get storageModeAppFolderSubtitle => 'Use default Music/SpotiFLAC path';
|
||||
|
||||
@override
|
||||
String get storageModeSaf => 'SAF folder';
|
||||
String get storageModeSaf => 'SAF-Ordner';
|
||||
|
||||
@override
|
||||
String get storageModeSafSubtitle =>
|
||||
@@ -2874,7 +2905,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
'Customize how your files are named.';
|
||||
|
||||
@override
|
||||
String get downloadFilenameInsertTag => 'Tap to insert tag:';
|
||||
String get downloadFilenameInsertTag => 'Tippe, um Tag einzufügen:';
|
||||
|
||||
@override
|
||||
String get downloadSeparateSinglesEnabled => 'Albums/ and Singles/ folders';
|
||||
@@ -2902,10 +2933,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
String get downloadSongLinkRegion => 'SongLink-Region';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityMode => 'Network compatibility mode';
|
||||
String get downloadNetworkCompatibilityMode => 'Netzwerkkompatibilitätsmodus';
|
||||
|
||||
@override
|
||||
String get downloadNetworkCompatibilityModeEnabled =>
|
||||
@@ -2948,7 +2979,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
'Append romanized lyrics when available';
|
||||
|
||||
@override
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Disabled';
|
||||
String get downloadNeteaseIncludeRomanizationDisabled => 'Deaktiviert';
|
||||
|
||||
@override
|
||||
String get downloadAppleQqMultiPerson => 'Apple/QQ Multi-Person Word-by-Word';
|
||||
@@ -2980,10 +3011,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
'Keep full Album Artist metadata value';
|
||||
|
||||
@override
|
||||
String get downloadProvidersNoneEnabled => 'None enabled';
|
||||
String get downloadProvidersNoneEnabled => 'Keine aktiviert';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageCode => 'Language code';
|
||||
String get downloadMusixmatchLanguageCode => 'Sprach-Code';
|
||||
|
||||
@override
|
||||
String get downloadMusixmatchLanguageHint => 'auto / en / es / ja';
|
||||
@@ -2996,7 +3027,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get downloadMusixmatchAuto => 'Auto';
|
||||
|
||||
@override
|
||||
String get downloadNetworkAnySubtitle => 'WiFi + Mobile Data';
|
||||
String get downloadNetworkAnySubtitle => 'WLAN + Mobile Daten';
|
||||
|
||||
@override
|
||||
String get downloadNetworkWifiOnlySubtitle =>
|
||||
@@ -3010,23 +3041,23 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get snackbarUnsupportedAudioFormat => 'Unsupported audio format';
|
||||
|
||||
@override
|
||||
String get cacheRefresh => 'Refresh';
|
||||
String get cacheRefresh => 'Aktualisieren';
|
||||
|
||||
@override
|
||||
String dialogDownloadPlaylistsMessage(int trackCount, int playlistCount) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
trackCount,
|
||||
locale: localeName,
|
||||
other: 'tracks',
|
||||
one: 'track',
|
||||
other: 'Titel',
|
||||
one: 'Titel',
|
||||
);
|
||||
String _temp1 = intl.Intl.pluralLogic(
|
||||
playlistCount,
|
||||
locale: localeName,
|
||||
other: 'playlists',
|
||||
one: 'playlist',
|
||||
other: 'Playlists',
|
||||
one: 'Playlist',
|
||||
);
|
||||
return 'Download $trackCount $_temp0 from $playlistCount $_temp1?';
|
||||
return 'Lade $trackCount $_temp0 von $playlistCount $_temp1?';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -3066,7 +3097,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
'Select fields to fill automatically from online metadata';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillFetch => 'Fetch & Fill';
|
||||
String get editMetadataAutoFillFetch => 'Abrufen & Ausfüllen';
|
||||
|
||||
@override
|
||||
String get editMetadataAutoFillSearching => 'Searching online...';
|
||||
@@ -3091,25 +3122,25 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
'Select at least one field to auto-fill';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTitle => 'Title';
|
||||
String get editMetadataFieldTitle => 'Titel';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldArtist => 'Artist';
|
||||
String get editMetadataFieldArtist => 'Künstler';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbum => 'Album';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldAlbumArtist => 'Album Artist';
|
||||
String get editMetadataFieldAlbumArtist => 'Album Künstler';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDate => 'Date';
|
||||
String get editMetadataFieldDate => 'Datum';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldTrackNum => 'Track #';
|
||||
String get editMetadataFieldTrackNum => 'Titel #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldDiscNum => 'Disc #';
|
||||
String get editMetadataFieldDiscNum => 'Disk #';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldGenre => 'Genre';
|
||||
@@ -3121,16 +3152,16 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get editMetadataFieldLabel => 'Label';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCopyright => 'Copyright';
|
||||
String get editMetadataFieldCopyright => 'Urheberrecht';
|
||||
|
||||
@override
|
||||
String get editMetadataFieldCover => 'Cover Art';
|
||||
String get editMetadataFieldCover => 'Cover-Art';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectAll => 'All';
|
||||
String get editMetadataSelectAll => 'Alle';
|
||||
|
||||
@override
|
||||
String get editMetadataSelectEmpty => 'Empty only';
|
||||
String get editMetadataSelectEmpty => 'Nur leer';
|
||||
|
||||
@override
|
||||
String queueDownloadingCount(int count) {
|
||||
@@ -3138,10 +3169,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueDownloadedHeader => 'Downloaded';
|
||||
String get queueDownloadedHeader => 'Heruntergeladen';
|
||||
|
||||
@override
|
||||
String get queueFilteringIndicator => 'Filtering...';
|
||||
String get queueFilteringIndicator => 'Filtere...';
|
||||
|
||||
@override
|
||||
String queueTrackCount(int count) {
|
||||
@@ -3166,7 +3197,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbums => 'No album downloads';
|
||||
String get queueEmptyAlbums => 'Keine Album-Downloads';
|
||||
|
||||
@override
|
||||
String get queueEmptyAlbumsSubtitle =>
|
||||
@@ -3192,7 +3223,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
|
||||
|
||||
@override
|
||||
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
|
||||
String get selectionSelectPlaylistsToDelete => 'Playlist zum Löschen wählen';
|
||||
|
||||
@override
|
||||
String get audioAnalysisTitle => 'Audio Quality Analysis';
|
||||
@@ -3202,37 +3233,37 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
'Verify lossless quality with spectrum analysis';
|
||||
|
||||
@override
|
||||
String get audioAnalysisAnalyzing => 'Analyzing audio...';
|
||||
String get audioAnalysisAnalyzing => 'Audio wird analysiert...';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSampleRate => 'Sample Rate';
|
||||
|
||||
@override
|
||||
String get audioAnalysisBitDepth => 'Bit Depth';
|
||||
String get audioAnalysisBitDepth => 'Bit-Tiefe';
|
||||
|
||||
@override
|
||||
String get audioAnalysisChannels => 'Channels';
|
||||
String get audioAnalysisChannels => 'Kanäle';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDuration => 'Duration';
|
||||
String get audioAnalysisDuration => 'Länge';
|
||||
|
||||
@override
|
||||
String get audioAnalysisNyquist => 'Nyquist';
|
||||
|
||||
@override
|
||||
String get audioAnalysisFileSize => 'Size';
|
||||
String get audioAnalysisFileSize => 'Größe';
|
||||
|
||||
@override
|
||||
String get audioAnalysisDynamicRange => 'Dynamic Range';
|
||||
String get audioAnalysisDynamicRange => 'Dynamischer Bereich';
|
||||
|
||||
@override
|
||||
String get audioAnalysisPeak => 'Peak';
|
||||
String get audioAnalysisPeak => 'Maximum';
|
||||
|
||||
@override
|
||||
String get audioAnalysisRms => 'RMS';
|
||||
|
||||
@override
|
||||
String get audioAnalysisSamples => 'Samples';
|
||||
String get audioAnalysisSamples => 'Proben';
|
||||
|
||||
@override
|
||||
String extensionsSearchWith(String providerName) {
|
||||
@@ -3240,7 +3271,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionsHomeFeedProvider => 'Home Feed Provider';
|
||||
String get extensionsHomeFeedProvider => 'Home Feed Anbieter';
|
||||
|
||||
@override
|
||||
String get extensionsHomeFeedDescription =>
|
||||
@@ -3268,7 +3299,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get sortAlphaDesc => 'Z-A';
|
||||
|
||||
@override
|
||||
String get cancelDownloadTitle => 'Cancel download?';
|
||||
String get cancelDownloadTitle => 'Download abbrechen?';
|
||||
|
||||
@override
|
||||
String cancelDownloadContent(String trackName) {
|
||||
@@ -3276,7 +3307,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get cancelDownloadKeep => 'Keep';
|
||||
String get cancelDownloadKeep => 'Behalten';
|
||||
|
||||
@override
|
||||
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
|
||||
@@ -3291,22 +3322,22 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get errorLoadAlbum => 'Failed to load album';
|
||||
String get errorLoadAlbum => 'Fehler beim Laden des Albums';
|
||||
|
||||
@override
|
||||
String get errorLoadPlaylist => 'Failed to load playlist';
|
||||
String get errorLoadPlaylist => 'Fehler beim Laden der Playlist';
|
||||
|
||||
@override
|
||||
String get errorLoadArtist => 'Failed to load artist';
|
||||
String get errorLoadArtist => 'Fehler beim Laden des Interpreten';
|
||||
|
||||
@override
|
||||
String get notifChannelDownloadName => 'Download Progress';
|
||||
String get notifChannelDownloadName => 'Download Fortschritt';
|
||||
|
||||
@override
|
||||
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
|
||||
|
||||
@override
|
||||
String get notifChannelLibraryScanName => 'Library Scan';
|
||||
String get notifChannelLibraryScanName => 'Bibliotheksscan';
|
||||
|
||||
@override
|
||||
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
|
||||
@@ -3330,7 +3361,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get notifAlreadyInLibrary => 'Already in Library';
|
||||
String get notifAlreadyInLibrary => 'Bereits in der Bibliothek';
|
||||
|
||||
@override
|
||||
String notifDownloadCompleteCount(int completed, int total) {
|
||||
@@ -3338,7 +3369,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get notifDownloadComplete => 'Download Complete';
|
||||
String get notifDownloadComplete => 'Download abgeschlossen';
|
||||
|
||||
@override
|
||||
String notifDownloadsFinished(int completed, int failed) {
|
||||
@@ -3380,12 +3411,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String notifLibraryScanExcluded(int count) {
|
||||
return '$count excluded';
|
||||
return '$count ausgeschlossen';
|
||||
}
|
||||
|
||||
@override
|
||||
String notifLibraryScanErrors(int count) {
|
||||
return '$count errors';
|
||||
return '$count Fehler';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -3408,7 +3439,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get notifUpdateReady => 'Update Ready';
|
||||
String get notifUpdateReady => 'Update bereit';
|
||||
|
||||
@override
|
||||
String notifUpdateReadyBody(String version) {
|
||||
@@ -3416,7 +3447,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
}
|
||||
|
||||
@override
|
||||
String get notifUpdateFailed => 'Update Failed';
|
||||
String get notifUpdateFailed => 'Update fehlgeschlagen';
|
||||
|
||||
@override
|
||||
String get notifUpdateFailedBody =>
|
||||
|
||||
@@ -127,6 +127,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
return 'Using extension: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
@@ -334,6 +341,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'PC source code';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Report an issue';
|
||||
|
||||
@@ -926,6 +936,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
|
||||
@@ -1415,6 +1436,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'No extensions with download provider';
|
||||
|
||||
@@ -21,13 +21,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get navSettings => 'Paramètres';
|
||||
|
||||
@override
|
||||
String get navStore => 'Magasin';
|
||||
String get navStore => 'Repo';
|
||||
|
||||
@override
|
||||
String get homeTitle => 'Accueil';
|
||||
|
||||
@override
|
||||
String get homeSubtitle => 'Coller un lien Spotify ou rechercher par nom';
|
||||
String get homeSubtitle => 'Paste a supported URL or search by name';
|
||||
|
||||
@override
|
||||
String get homeSupports => 'Supports: Piste, Album, Playlist, Artiste URLs';
|
||||
@@ -128,6 +128,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
return 'Utilisation de l\'extension: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Appuyez sur Deezer ou Spotify pour revenir à l\'extension';
|
||||
@@ -211,10 +218,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
'Parallel downloads may trigger rate limiting';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Extension Store';
|
||||
String get optionsExtensionStore => 'Extension Repo';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
|
||||
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
|
||||
|
||||
@override
|
||||
String get optionsCheckUpdates => 'Check for Updates';
|
||||
@@ -275,7 +282,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get extensionsTitle => 'Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsDisabled => 'Disabled';
|
||||
String get extensionsDisabled => 'Désactivée';
|
||||
|
||||
@override
|
||||
String extensionsVersion(String version) {
|
||||
@@ -284,38 +291,38 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String extensionsAuthor(String author) {
|
||||
return 'by $author';
|
||||
return 'par $author';
|
||||
}
|
||||
|
||||
@override
|
||||
String get extensionsUninstall => 'Désinstaller';
|
||||
|
||||
@override
|
||||
String get storeTitle => 'Magasin d\'extension';
|
||||
String get storeTitle => 'Extension Repo';
|
||||
|
||||
@override
|
||||
String get storeSearch => 'Recherche d\'extensions...';
|
||||
|
||||
@override
|
||||
String get storeInstall => 'Install';
|
||||
String get storeInstall => 'Installer';
|
||||
|
||||
@override
|
||||
String get storeInstalled => 'Installed';
|
||||
String get storeInstalled => 'Installé';
|
||||
|
||||
@override
|
||||
String get storeUpdate => 'Update';
|
||||
String get storeUpdate => 'Mettre à jour';
|
||||
|
||||
@override
|
||||
String get aboutTitle => 'About';
|
||||
String get aboutTitle => 'À propos de';
|
||||
|
||||
@override
|
||||
String get aboutContributors => 'Contributors';
|
||||
String get aboutContributors => 'Contributeurs';
|
||||
|
||||
@override
|
||||
String get aboutMobileDeveloper => 'Mobile version developer';
|
||||
String get aboutMobileDeveloper => 'Développeur de la version mobile';
|
||||
|
||||
@override
|
||||
String get aboutOriginalCreator => 'Creator of the original SpotiFLAC';
|
||||
String get aboutOriginalCreator => 'Créateur de SpotiFLAC original';
|
||||
|
||||
@override
|
||||
String get aboutLogoArtist =>
|
||||
@@ -336,6 +343,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'PC source code';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Report an issue';
|
||||
|
||||
@@ -352,7 +362,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get aboutTelegramChannel => 'Telegram Channel';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChannelSubtitle => 'Announcements and updates';
|
||||
String get aboutTelegramChannelSubtitle => 'Annonces et mises à jour';
|
||||
|
||||
@override
|
||||
String get aboutTelegramChat => 'Telegram Community';
|
||||
@@ -510,10 +520,11 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
'SpotiFLAC needs storage permission to save your downloaded music files.';
|
||||
|
||||
@override
|
||||
String get setupNotificationGranted => 'Notification Permission Granted!';
|
||||
String get setupNotificationGranted =>
|
||||
'Autorisation de notifications accordée!';
|
||||
|
||||
@override
|
||||
String get setupNotificationEnable => 'Enable Notifications';
|
||||
String get setupNotificationEnable => 'Activer les notifications';
|
||||
|
||||
@override
|
||||
String get setupFolderChoose => 'Choisissez le dossier pour télécharger';
|
||||
@@ -523,39 +534,39 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
'Sélectionnez un dossier dans lequel votre musique téléchargée sera enregistrée.';
|
||||
|
||||
@override
|
||||
String get setupSelectFolder => 'Select Folder';
|
||||
String get setupSelectFolder => 'Sélectionner un dossier';
|
||||
|
||||
@override
|
||||
String get setupEnableNotifications => 'Enable Notifications';
|
||||
String get setupEnableNotifications => 'Activer les notifications';
|
||||
|
||||
@override
|
||||
String get setupNotificationBackgroundDescription =>
|
||||
'Get notified about download progress and completion. This helps you track downloads when the app is in background.';
|
||||
|
||||
@override
|
||||
String get setupSkipForNow => 'Skip for now';
|
||||
String get setupSkipForNow => 'Ignorer pour le moment';
|
||||
|
||||
@override
|
||||
String get setupNext => 'Next';
|
||||
String get setupNext => 'Suivant';
|
||||
|
||||
@override
|
||||
String get setupGetStarted => 'Get Started';
|
||||
String get setupGetStarted => 'Démarrer';
|
||||
|
||||
@override
|
||||
String get setupAllowAccessToManageFiles =>
|
||||
'Please enable \"Allow access to manage all files\" in the next screen.';
|
||||
'Veuillez activer \"Autoriser l\'accès à tous les fichiers\" sur l\'écran suivant.';
|
||||
|
||||
@override
|
||||
String get dialogCancel => 'Cancel';
|
||||
String get dialogCancel => 'Annuler';
|
||||
|
||||
@override
|
||||
String get dialogSave => 'Save';
|
||||
String get dialogSave => 'Sauvegarder';
|
||||
|
||||
@override
|
||||
String get dialogDelete => 'Delete';
|
||||
String get dialogDelete => 'Supprimer';
|
||||
|
||||
@override
|
||||
String get dialogRetry => 'Retry';
|
||||
String get dialogRetry => 'Réessayer';
|
||||
|
||||
@override
|
||||
String get dialogClear => 'Clear';
|
||||
@@ -567,7 +578,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get dialogImport => 'Import';
|
||||
|
||||
@override
|
||||
String get dialogDownload => 'Download';
|
||||
String get dialogDownload => 'Télécharger';
|
||||
|
||||
@override
|
||||
String get dialogDiscard => 'Discard';
|
||||
@@ -576,10 +587,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get dialogRemove => 'Remove';
|
||||
|
||||
@override
|
||||
String get dialogUninstall => 'Uninstall';
|
||||
String get dialogUninstall => 'Désinstaller';
|
||||
|
||||
@override
|
||||
String get dialogDiscardChanges => 'Discard Changes?';
|
||||
String get dialogDiscardChanges => 'Ignorer les modifications ?';
|
||||
|
||||
@override
|
||||
String get dialogUnsavedChanges =>
|
||||
@@ -928,6 +939,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
|
||||
@@ -1317,7 +1339,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
||||
@@ -1417,6 +1439,13 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'No extensions with download provider';
|
||||
@@ -2088,7 +2117,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
'Browse the Repo tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
@@ -2369,7 +2398,8 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
|
||||
@@ -21,13 +21,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get navSettings => 'विकल्प';
|
||||
|
||||
@override
|
||||
String get navStore => 'Store';
|
||||
String get navStore => 'Repo';
|
||||
|
||||
@override
|
||||
String get homeTitle => 'Home';
|
||||
|
||||
@override
|
||||
String get homeSubtitle => 'Paste a Spotify link or search by name';
|
||||
String get homeSubtitle => 'Paste a supported URL or search by name';
|
||||
|
||||
@override
|
||||
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
||||
@@ -127,6 +127,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
return 'Using extension: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
@@ -209,10 +216,10 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
'Parallel downloads may trigger rate limiting';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Extension Store';
|
||||
String get optionsExtensionStore => 'Extension Repo';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
|
||||
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
|
||||
|
||||
@override
|
||||
String get optionsCheckUpdates => 'Check for Updates';
|
||||
@@ -289,7 +296,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get extensionsUninstall => 'Uninstall';
|
||||
|
||||
@override
|
||||
String get storeTitle => 'Extension Store';
|
||||
String get storeTitle => 'Extension Repo';
|
||||
|
||||
@override
|
||||
String get storeSearch => 'Search extensions...';
|
||||
@@ -334,6 +341,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'PC source code';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Report an issue';
|
||||
|
||||
@@ -926,6 +936,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
|
||||
@@ -1315,7 +1336,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
||||
@@ -1415,6 +1436,13 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'No extensions with download provider';
|
||||
@@ -2086,7 +2114,7 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
'Browse the Repo tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
@@ -2367,7 +2395,8 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
|
||||
@@ -27,8 +27,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get homeTitle => 'Beranda';
|
||||
|
||||
@override
|
||||
String get homeSubtitle =>
|
||||
'Tempel URL yang didukung atau cari berdasarkan nama';
|
||||
String get homeSubtitle => 'Paste a supported URL or search by name';
|
||||
|
||||
@override
|
||||
String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis';
|
||||
@@ -129,6 +128,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
return 'Menggunakan ekstensi: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Ketuk Deezer atau Spotify untuk beralih dari ekstensi';
|
||||
@@ -213,10 +219,10 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
'Unduhan paralel dapat memicu pembatasan rate';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Repo Ekstensi';
|
||||
String get optionsExtensionStore => 'Extension Repo';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStoreSubtitle => 'Tampilkan tab Repo di navigasi';
|
||||
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
|
||||
|
||||
@override
|
||||
String get optionsCheckUpdates => 'Periksa Pembaruan';
|
||||
@@ -292,7 +298,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get extensionsUninstall => 'Copot';
|
||||
|
||||
@override
|
||||
String get storeTitle => 'Repo Ekstensi';
|
||||
String get storeTitle => 'Extension Repo';
|
||||
|
||||
@override
|
||||
String get storeSearch => 'Cari ekstensi...';
|
||||
@@ -337,6 +343,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'Kode sumber PC';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Laporkan masalah';
|
||||
|
||||
@@ -735,15 +744,15 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get errorNoTracksFound => 'Tidak ada lagu ditemukan';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognized => 'Link tidak dikenali';
|
||||
String get errorUrlNotRecognized => 'Tautan tidak dikenali';
|
||||
|
||||
@override
|
||||
String get errorUrlNotRecognizedMessage =>
|
||||
'Link ini tidak didukung. Pastikan URL benar dan ekstensi yang kompatibel sudah terpasang.';
|
||||
'Tautan ini tidak didukung. Pastikan URL sudah benar dan ekstensi yang kompatibel telah terpasang.';
|
||||
|
||||
@override
|
||||
String get errorUrlFetchFailed =>
|
||||
'Gagal memuat konten dari link ini. Silakan coba lagi.';
|
||||
'Konten dari tautan ini gagal dimuat. Silakan coba lagi.';
|
||||
|
||||
@override
|
||||
String errorMissingExtensionSource(String item) {
|
||||
@@ -930,6 +939,17 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Bawaan';
|
||||
|
||||
@@ -1313,7 +1333,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get storeNewRepoUrlLabel => 'New Repository URL';
|
||||
|
||||
@override
|
||||
String get storeLoadError => 'Gagal memuat repo';
|
||||
String get storeLoadError => 'Failed to load repository';
|
||||
|
||||
@override
|
||||
String get storeEmptyNoExtensions => 'No extensions available';
|
||||
@@ -1322,7 +1342,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Bawaan (Deezer/Spotify)';
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Gunakan pencarian bawaan';
|
||||
@@ -1423,6 +1443,13 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get extensionsDownloadPrioritySubtitle =>
|
||||
'Atur urutan layanan unduhan';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'Tidak ada ekstensi dengan provider unduhan';
|
||||
@@ -2096,7 +2123,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Buka tab Repo untuk menemukan ekstensi yang berguna';
|
||||
'Browse the Repo tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
@@ -2347,25 +2374,25 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
|
||||
|
||||
@override
|
||||
String get queueFlacAction => 'Antrekan FLAC';
|
||||
String get queueFlacAction => 'Queue FLAC';
|
||||
|
||||
@override
|
||||
String queueFlacConfirmMessage(int count) {
|
||||
return 'Cari kecocokan online untuk track yang dipilih lalu antrekan download FLAC.\n\nFile yang sudah ada tidak akan diubah atau dihapus.\n\nHanya kecocokan dengan keyakinan tinggi yang akan diantrikan otomatis.\n\n$count dipilih';
|
||||
return 'Search online matches for the selected tracks and queue FLAC downloads.\n\nExisting files will not be modified or deleted.\n\nOnly high-confidence matches are queued automatically.\n\n$count selected';
|
||||
}
|
||||
|
||||
@override
|
||||
String queueFlacFindingProgress(int current, int total) {
|
||||
return 'Mencari kecocokan FLAC... ($current/$total)';
|
||||
return 'Finding FLAC matches... ($current/$total)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get queueFlacNoReliableMatches =>
|
||||
'Tidak ada kecocokan online yang cukup meyakinkan untuk pilihan ini';
|
||||
'No reliable online matches found for the selection';
|
||||
|
||||
@override
|
||||
String queueFlacQueuedWithSkipped(int addedCount, int skippedCount) {
|
||||
return 'Menambahkan $addedCount track ke antrean, melewati $skippedCount';
|
||||
return 'Added $addedCount tracks to queue, skipped $skippedCount';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -2378,7 +2405,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Konversi ke MP3, Opus, ALAC, atau FLAC';
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
@@ -2406,12 +2433,12 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String sourceFormat,
|
||||
String targetFormat,
|
||||
) {
|
||||
return 'Konversi dari $sourceFormat ke $targetFormat? (Lossless — tanpa kehilangan kualitas)\n\nFile asli akan dihapus setelah konversi.';
|
||||
return 'Convert from $sourceFormat to $targetFormat? (Lossless — no quality loss)\n\nThe original file will be deleted after conversion.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get trackConvertLosslessHint =>
|
||||
'Konversi lossless — tanpa kehilangan kualitas';
|
||||
'Lossless conversion — no quality loss';
|
||||
|
||||
@override
|
||||
String get trackConvertConverting => 'Converting audio...';
|
||||
@@ -2865,19 +2892,19 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolder =>
|
||||
'Buat folder sumber playlist';
|
||||
'Create playlist source folder';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderEnabled =>
|
||||
'Unduhan dari playlist memakai Playlist/ lalu struktur folder normal Anda.';
|
||||
'Playlist downloads use Playlist/ plus your normal folder structure.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderDisabled =>
|
||||
'Unduhan dari playlist hanya memakai struktur folder normal.';
|
||||
'Playlist downloads use the normal folder structure only.';
|
||||
|
||||
@override
|
||||
String get downloadCreatePlaylistSourceFolderRedundant =>
|
||||
'Mode Berdasarkan Playlist sudah menaruh unduhan ke dalam folder playlist.';
|
||||
'By Playlist already places downloads inside a playlist folder.';
|
||||
|
||||
@override
|
||||
String get downloadSongLinkRegion => 'SongLink Region';
|
||||
|
||||
@@ -21,13 +21,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get navSettings => '設定';
|
||||
|
||||
@override
|
||||
String get navStore => 'ストア';
|
||||
String get navStore => 'Repo';
|
||||
|
||||
@override
|
||||
String get homeTitle => 'ホーム';
|
||||
|
||||
@override
|
||||
String get homeSubtitle => 'Spotify のリンクを貼り付けるか、名前で検索します';
|
||||
String get homeSubtitle => 'Paste a supported URL or search by name';
|
||||
|
||||
@override
|
||||
String get homeSupports => 'サポート: トラック、アルバム、プレイリスト、アーティスト、URL';
|
||||
@@ -127,6 +127,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
return '拡張の使用: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
@@ -207,10 +214,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
'Parallel downloads may trigger rate limiting';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => '拡張ストア';
|
||||
String get optionsExtensionStore => 'Extension Repo';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStoreSubtitle => 'ナビゲーションにストアタブを表示';
|
||||
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
|
||||
|
||||
@override
|
||||
String get optionsCheckUpdates => '更新を確認';
|
||||
@@ -286,7 +293,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get extensionsUninstall => 'アンインストール';
|
||||
|
||||
@override
|
||||
String get storeTitle => '拡張ストア';
|
||||
String get storeTitle => 'Extension Repo';
|
||||
|
||||
@override
|
||||
String get storeSearch => '拡張を検索...';
|
||||
@@ -330,6 +337,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'PC 版のソースコード';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => '問題を報告する';
|
||||
|
||||
@@ -920,6 +930,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => '内蔵';
|
||||
|
||||
@@ -1309,7 +1330,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'デフォルト (Deezer/Spotify)';
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => '内蔵の検索を使用する';
|
||||
@@ -1409,6 +1430,13 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get extensionsDownloadPrioritySubtitle => 'ダウンロードサービスの順序を設定';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider => 'ダウンロードプロバイダーの拡張はありません';
|
||||
|
||||
@@ -2073,7 +2101,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
'Browse the Repo tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
@@ -2354,7 +2382,8 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
String get trackConvertFormat => '変換の形式';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'MP3 または Opus に変換';
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'オーディオを変換';
|
||||
|
||||
@@ -21,13 +21,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get navSettings => 'Settings';
|
||||
|
||||
@override
|
||||
String get navStore => 'Store';
|
||||
String get navStore => 'Repo';
|
||||
|
||||
@override
|
||||
String get homeTitle => 'Home';
|
||||
|
||||
@override
|
||||
String get homeSubtitle => 'Spotify URL을 붙여 넣거나 검색';
|
||||
String get homeSubtitle => 'Paste a supported URL or search by name';
|
||||
|
||||
@override
|
||||
String get homeSupports => '지원 항목: 트랙, 앨범, 플레이리스트, 아티스트 URLs';
|
||||
@@ -125,6 +125,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
return '확장 기능을 사용: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack => 'Deezer 또는 Spotify를 탭하여 확장 기능에서 다시 전환하세요.';
|
||||
|
||||
@@ -202,10 +209,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get optionsConcurrentWarning => '동시에 다수의 음반을 다운로드하면 속도 제한이 발생할 수 있습니다';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => '확장 기능 스토어';
|
||||
String get optionsExtensionStore => 'Extension Repo';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStoreSubtitle => '탐색 메뉴에 스토어 탭 표시';
|
||||
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
|
||||
|
||||
@override
|
||||
String get optionsCheckUpdates => '업데이트 확인';
|
||||
@@ -279,7 +286,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get extensionsUninstall => '삭제';
|
||||
|
||||
@override
|
||||
String get storeTitle => '확장 기능 스토어';
|
||||
String get storeTitle => 'Extension Repo';
|
||||
|
||||
@override
|
||||
String get storeSearch => '확장 기능 검색';
|
||||
@@ -323,6 +330,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'PC 소스 코드';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => '문제 신고';
|
||||
|
||||
@@ -908,6 +918,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
|
||||
@@ -1295,7 +1316,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
||||
@@ -1395,6 +1416,13 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'No extensions with download provider';
|
||||
@@ -2066,7 +2094,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
'Browse the Repo tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
@@ -2347,7 +2375,8 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
|
||||
@@ -21,13 +21,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get navSettings => 'Settings';
|
||||
|
||||
@override
|
||||
String get navStore => 'Store';
|
||||
String get navStore => 'Repo';
|
||||
|
||||
@override
|
||||
String get homeTitle => 'Home';
|
||||
|
||||
@override
|
||||
String get homeSubtitle => 'Paste a Spotify link or search by name';
|
||||
String get homeSubtitle => 'Paste a supported URL or search by name';
|
||||
|
||||
@override
|
||||
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
|
||||
@@ -127,6 +127,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
return 'Using extension: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Tap Deezer or Spotify to switch back from extension';
|
||||
@@ -209,10 +216,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
'Parallel downloaden kan leiden tot rate-limiting';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Extension Store';
|
||||
String get optionsExtensionStore => 'Extension Repo';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
|
||||
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
|
||||
|
||||
@override
|
||||
String get optionsCheckUpdates => 'Check for Updates';
|
||||
@@ -289,7 +296,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get extensionsUninstall => 'Uninstall';
|
||||
|
||||
@override
|
||||
String get storeTitle => 'Extension Store';
|
||||
String get storeTitle => 'Extension Repo';
|
||||
|
||||
@override
|
||||
String get storeSearch => 'Search extensions...';
|
||||
@@ -334,6 +341,9 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'PC source code';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Report an issue';
|
||||
|
||||
@@ -926,6 +936,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'If a track is not available on the first provider, the app will automatically try the next one.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Built-in';
|
||||
|
||||
@@ -1315,7 +1336,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'Default (Deezer/Spotify)';
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle => 'Use built-in search';
|
||||
@@ -1415,6 +1436,13 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
@override
|
||||
String get extensionsDownloadPrioritySubtitle => 'Set download service order';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'No extensions with download provider';
|
||||
@@ -2086,7 +2114,7 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Browse the Store tab to discover useful extensions';
|
||||
'Browse the Repo tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
@@ -2367,7 +2395,8 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get trackConvertFormat => 'Convert Format';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Convert Audio';
|
||||
|
||||
@@ -21,13 +21,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get navSettings => 'Настройки';
|
||||
|
||||
@override
|
||||
String get navStore => 'Магазин';
|
||||
String get navStore => 'Repo';
|
||||
|
||||
@override
|
||||
String get homeTitle => 'Главная';
|
||||
|
||||
@override
|
||||
String get homeSubtitle => 'Вставьте ссылку Spotify или ищите по названию';
|
||||
String get homeSubtitle => 'Paste a supported URL or search by name';
|
||||
|
||||
@override
|
||||
String get homeSupports =>
|
||||
@@ -129,6 +129,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
return 'Используется расширение: $extensionName';
|
||||
}
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTab => 'Default Search Tab';
|
||||
|
||||
@override
|
||||
String get optionsDefaultSearchTabSubtitle =>
|
||||
'Choose which tab opens first for new search results.';
|
||||
|
||||
@override
|
||||
String get optionsSwitchBack =>
|
||||
'Нажмите Deezer или Spotify для возврата с расширения';
|
||||
@@ -214,11 +221,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
'Параллельные загрузки могут вызвать ограничение скорости';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStore => 'Магазин расширений';
|
||||
String get optionsExtensionStore => 'Extension Repo';
|
||||
|
||||
@override
|
||||
String get optionsExtensionStoreSubtitle =>
|
||||
'Показывать вкладку Магазин в гл. меню';
|
||||
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
|
||||
|
||||
@override
|
||||
String get optionsCheckUpdates => 'Проверить обновления';
|
||||
@@ -295,7 +301,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get extensionsUninstall => 'Удалить';
|
||||
|
||||
@override
|
||||
String get storeTitle => 'Магазин расширений';
|
||||
String get storeTitle => 'Extension Repo';
|
||||
|
||||
@override
|
||||
String get storeSearch => 'Поиск расширений...';
|
||||
@@ -340,6 +346,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get aboutPCSource => 'Исходный код ПК версии';
|
||||
|
||||
@override
|
||||
String get aboutKeepAndroidOpen => 'Keep Android Open';
|
||||
|
||||
@override
|
||||
String get aboutReportIssue => 'Сообщить о проблеме';
|
||||
|
||||
@@ -626,9 +635,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0 из истории?\n\nЭто также удалит файлы из хранилища.';
|
||||
}
|
||||
@@ -681,9 +690,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалено $count $_temp0';
|
||||
}
|
||||
@@ -940,6 +949,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get providerPriorityInfo =>
|
||||
'Если трек не доступен у первого провайдера, приложение автоматически попробует следующий.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsTitle => 'Extension Fallback';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsDescription =>
|
||||
'Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.';
|
||||
|
||||
@override
|
||||
String get providerPriorityFallbackExtensionsHint =>
|
||||
'Only enabled extensions with download-provider capability are listed here.';
|
||||
|
||||
@override
|
||||
String get providerBuiltIn => 'Встроенные';
|
||||
|
||||
@@ -1140,9 +1160,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
one: '1 трек',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -1336,7 +1356,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get storeEmptyNoResults => 'No extensions found';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProvider => 'По умолчанию (Deezer/Spotify)';
|
||||
String get extensionDefaultProvider => 'Default (Deezer)';
|
||||
|
||||
@override
|
||||
String get extensionDefaultProviderSubtitle =>
|
||||
@@ -1439,6 +1459,13 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get extensionsDownloadPrioritySubtitle =>
|
||||
'Установка порядок сервисов скачивания';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackTitle => 'Fallback Extensions';
|
||||
|
||||
@override
|
||||
String get extensionsFallbackSubtitle =>
|
||||
'Choose which installed download extensions can be used as fallback';
|
||||
|
||||
@override
|
||||
String get extensionsNoDownloadProvider =>
|
||||
'Нет расширений с провайдером загрузки';
|
||||
@@ -1642,9 +1669,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.';
|
||||
}
|
||||
@@ -1666,9 +1693,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Удалить $count $_temp0';
|
||||
}
|
||||
@@ -1899,9 +1926,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -2055,9 +2082,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count минут',
|
||||
one: '1 минуту',
|
||||
many: '$count минут',
|
||||
few: '$count минуты',
|
||||
one: '$count минуту',
|
||||
);
|
||||
return '$_temp0 назад';
|
||||
}
|
||||
@@ -2068,9 +2095,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count часов',
|
||||
one: '1 час',
|
||||
many: '$count часов',
|
||||
few: '$count часа',
|
||||
one: '$count час',
|
||||
);
|
||||
return '$_temp0 назад';
|
||||
}
|
||||
@@ -2136,7 +2163,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip1 =>
|
||||
'Просмотрите вкладку Магазина, чтобы найти полезные расширения';
|
||||
'Browse the Repo tab to discover useful extensions';
|
||||
|
||||
@override
|
||||
String get tutorialExtensionsTip2 =>
|
||||
@@ -2420,7 +2447,8 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get trackConvertFormat => 'Переконвертировать формат';
|
||||
|
||||
@override
|
||||
String get trackConvertFormatSubtitle => 'Конвертировать в MP3 или Opus';
|
||||
String get trackConvertFormatSubtitle =>
|
||||
'Convert to MP3, Opus, ALAC, or FLAC';
|
||||
|
||||
@override
|
||||
String get trackConvertTitle => 'Конвертировать аудио';
|
||||
@@ -2551,9 +2579,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count треков',
|
||||
one: '1 трек',
|
||||
many: '$count треков',
|
||||
few: '$count трека',
|
||||
one: '$count трек',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
@@ -2670,9 +2698,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Отправить $count $_temp0';
|
||||
}
|
||||
@@ -2687,9 +2715,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'треков',
|
||||
one: 'трек',
|
||||
many: 'треков',
|
||||
few: 'трека',
|
||||
one: 'трек',
|
||||
);
|
||||
return 'Конвертировать $count $_temp0';
|
||||
}
|
||||
|
||||
@@ -158,6 +158,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsDefaultSearchTab": "Default Search Tab",
|
||||
"@optionsDefaultSearchTab": {
|
||||
"description": "Title for the preferred default search tab setting"
|
||||
},
|
||||
"optionsDefaultSearchTabSubtitle": "Choose which tab opens first for new search results.",
|
||||
"@optionsDefaultSearchTabSubtitle": {
|
||||
"description": "Subtitle for the preferred default search tab setting"
|
||||
},
|
||||
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
|
||||
"@optionsSwitchBack": {
|
||||
"description": "Hint to switch back to built-in providers"
|
||||
@@ -422,6 +430,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Report an issue",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
@@ -1203,6 +1215,18 @@
|
||||
"@providerPriorityInfo": {
|
||||
"description": "Info tip about fallback behavior"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsTitle": "Extension Fallback",
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsHint": "Only enabled extensions with download-provider capability are listed here.",
|
||||
"@providerPriorityFallbackExtensionsHint": {
|
||||
"description": "Hint below the extension fallback selection list"
|
||||
},
|
||||
"providerBuiltIn": "Built-in",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
@@ -1857,6 +1881,14 @@
|
||||
"@extensionsDownloadPrioritySubtitle": {
|
||||
"description": "Subtitle for download priority"
|
||||
},
|
||||
"extensionsFallbackTitle": "Fallback Extensions",
|
||||
"@extensionsFallbackTitle": {
|
||||
"description": "Setting and page title for choosing which download extensions can be used during fallback"
|
||||
},
|
||||
"extensionsFallbackSubtitle": "Choose which installed download extensions can be used as fallback",
|
||||
"@extensionsFallbackSubtitle": {
|
||||
"description": "Subtitle for download fallback extensions menu"
|
||||
},
|
||||
"extensionsNoDownloadProvider": "No extensions with download provider",
|
||||
"@extensionsNoDownloadProvider": {
|
||||
"description": "Empty state - no download providers"
|
||||
|
||||
@@ -362,6 +362,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Report an issue",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
|
||||
@@ -362,6 +362,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Report an issue",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
|
||||
@@ -362,6 +362,10 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Report an issue",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
|
||||
@@ -20,6 +20,7 @@ const List<Locale> filteredSupportedLocales = <Locale>[
|
||||
Locale('pt', 'PT'),
|
||||
Locale('ja'),
|
||||
Locale('tr'),
|
||||
Locale('uk'),
|
||||
];
|
||||
|
||||
/// Set of locale codes for quick lookup.
|
||||
@@ -31,4 +32,5 @@ const Set<String> filteredLocaleCodes = <String>{
|
||||
'pt_PT',
|
||||
'ja',
|
||||
'tr',
|
||||
'uk',
|
||||
};
|
||||
|
||||
@@ -33,7 +33,9 @@ class AppSettings {
|
||||
final bool askQualityBeforeDownload;
|
||||
final bool enableLogging;
|
||||
final bool useExtensionProviders;
|
||||
final List<String>? downloadFallbackExtensionIds;
|
||||
final String? searchProvider;
|
||||
final String defaultSearchTab;
|
||||
final String? homeFeedProvider;
|
||||
final bool separateSingles;
|
||||
final String singleFilenameFormat;
|
||||
@@ -108,7 +110,9 @@ class AppSettings {
|
||||
this.askQualityBeforeDownload = true,
|
||||
this.enableLogging = false,
|
||||
this.useExtensionProviders = true,
|
||||
this.downloadFallbackExtensionIds,
|
||||
this.searchProvider,
|
||||
this.defaultSearchTab = 'all',
|
||||
this.homeFeedProvider,
|
||||
this.separateSingles = false,
|
||||
this.singleFilenameFormat = '{title} - {artist}',
|
||||
@@ -170,8 +174,11 @@ class AppSettings {
|
||||
bool? askQualityBeforeDownload,
|
||||
bool? enableLogging,
|
||||
bool? useExtensionProviders,
|
||||
List<String>? downloadFallbackExtensionIds,
|
||||
bool clearDownloadFallbackExtensionIds = false,
|
||||
String? searchProvider,
|
||||
bool clearSearchProvider = false,
|
||||
String? defaultSearchTab,
|
||||
String? homeFeedProvider,
|
||||
bool clearHomeFeedProvider = false,
|
||||
bool? separateSingles,
|
||||
@@ -232,9 +239,13 @@ class AppSettings {
|
||||
enableLogging: enableLogging ?? this.enableLogging,
|
||||
useExtensionProviders:
|
||||
useExtensionProviders ?? this.useExtensionProviders,
|
||||
downloadFallbackExtensionIds: clearDownloadFallbackExtensionIds
|
||||
? null
|
||||
: (downloadFallbackExtensionIds ?? this.downloadFallbackExtensionIds),
|
||||
searchProvider: clearSearchProvider
|
||||
? null
|
||||
: (searchProvider ?? this.searchProvider),
|
||||
defaultSearchTab: defaultSearchTab ?? this.defaultSearchTab,
|
||||
homeFeedProvider: clearHomeFeedProvider
|
||||
? null
|
||||
: (homeFeedProvider ?? this.homeFeedProvider),
|
||||
|
||||
@@ -35,7 +35,12 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||
downloadFallbackExtensionIds:
|
||||
(json['downloadFallbackExtensionIds'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList(),
|
||||
searchProvider: json['searchProvider'] as String?,
|
||||
defaultSearchTab: json['defaultSearchTab'] as String? ?? 'all',
|
||||
homeFeedProvider: json['homeFeedProvider'] as String?,
|
||||
separateSingles: json['separateSingles'] as bool? ?? false,
|
||||
singleFilenameFormat:
|
||||
@@ -105,7 +110,9 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||
'enableLogging': instance.enableLogging,
|
||||
'useExtensionProviders': instance.useExtensionProviders,
|
||||
'downloadFallbackExtensionIds': instance.downloadFallbackExtensionIds,
|
||||
'searchProvider': instance.searchProvider,
|
||||
'defaultSearchTab': instance.defaultSearchTab,
|
||||
'homeFeedProvider': instance.homeFeedProvider,
|
||||
'separateSingles': instance.separateSingles,
|
||||
'singleFilenameFormat': instance.singleFilenameFormat,
|
||||
|
||||
@@ -16,12 +16,14 @@ class Track {
|
||||
final int duration;
|
||||
final int? trackNumber;
|
||||
final int? discNumber;
|
||||
final int? totalDiscs;
|
||||
final String? releaseDate;
|
||||
final String? deezerId;
|
||||
final ServiceAvailability? availability;
|
||||
final String? source;
|
||||
final String? albumType;
|
||||
final int? totalTracks;
|
||||
final String? composer;
|
||||
final String? itemType;
|
||||
|
||||
const Track({
|
||||
@@ -37,12 +39,14 @@ class Track {
|
||||
required this.duration,
|
||||
this.trackNumber,
|
||||
this.discNumber,
|
||||
this.totalDiscs,
|
||||
this.releaseDate,
|
||||
this.deezerId,
|
||||
this.availability,
|
||||
this.source,
|
||||
this.albumType,
|
||||
this.totalTracks,
|
||||
this.composer,
|
||||
this.itemType,
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||
duration: (json['duration'] as num).toInt(),
|
||||
trackNumber: (json['trackNumber'] as num?)?.toInt(),
|
||||
discNumber: (json['discNumber'] as num?)?.toInt(),
|
||||
totalDiscs: (json['totalDiscs'] as num?)?.toInt(),
|
||||
releaseDate: json['releaseDate'] as String?,
|
||||
deezerId: json['deezerId'] as String?,
|
||||
availability: json['availability'] == null
|
||||
@@ -29,6 +30,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||
source: json['source'] as String?,
|
||||
albumType: json['albumType'] as String?,
|
||||
totalTracks: (json['totalTracks'] as num?)?.toInt(),
|
||||
composer: json['composer'] as String?,
|
||||
itemType: json['itemType'] as String?,
|
||||
);
|
||||
|
||||
@@ -45,12 +47,14 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
'duration': instance.duration,
|
||||
'trackNumber': instance.trackNumber,
|
||||
'discNumber': instance.discNumber,
|
||||
'totalDiscs': instance.totalDiscs,
|
||||
'releaseDate': instance.releaseDate,
|
||||
'deezerId': instance.deezerId,
|
||||
'availability': instance.availability,
|
||||
'source': instance.source,
|
||||
'albumType': instance.albumType,
|
||||
'totalTracks': instance.totalTracks,
|
||||
'composer': instance.composer,
|
||||
'itemType': instance.itemType,
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ class Extension {
|
||||
final String name;
|
||||
final String displayName;
|
||||
final String version;
|
||||
final String author;
|
||||
final String description;
|
||||
final bool enabled;
|
||||
final String status;
|
||||
@@ -45,7 +44,6 @@ class Extension {
|
||||
required this.name,
|
||||
required this.displayName,
|
||||
required this.version,
|
||||
required this.author,
|
||||
required this.description,
|
||||
required this.enabled,
|
||||
required this.status,
|
||||
@@ -73,7 +71,6 @@ class Extension {
|
||||
displayName:
|
||||
json['display_name'] as String? ?? json['name'] as String? ?? '',
|
||||
version: json['version'] as String? ?? '0.0.0',
|
||||
author: json['author'] as String? ?? 'Unknown',
|
||||
description: json['description'] as String? ?? '',
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
status: json['status'] as String? ?? 'loaded',
|
||||
@@ -124,7 +121,6 @@ class Extension {
|
||||
String? name,
|
||||
String? displayName,
|
||||
String? version,
|
||||
String? author,
|
||||
String? description,
|
||||
bool? enabled,
|
||||
String? status,
|
||||
@@ -149,7 +145,6 @@ class Extension {
|
||||
name: name ?? this.name,
|
||||
displayName: displayName ?? this.displayName,
|
||||
version: version ?? this.version,
|
||||
author: author ?? this.author,
|
||||
description: description ?? this.description,
|
||||
enabled: enabled ?? this.enabled,
|
||||
status: status ?? this.status,
|
||||
@@ -178,6 +173,26 @@ class Extension {
|
||||
bool get hasPostProcessing => postProcessing?.enabled ?? false;
|
||||
bool get hasHomeFeed => capabilities['homeFeed'] == true;
|
||||
bool get hasBrowseCategories => capabilities['browseCategories'] == true;
|
||||
String? get preferredDownloadOutputExtension {
|
||||
final value = capabilities['downloadOutputExtension'];
|
||||
if (value is! String) return null;
|
||||
final trimmed = value.trim();
|
||||
return trimmed.isEmpty ? null : trimmed;
|
||||
}
|
||||
|
||||
List<String> get preservedNativeOutputExtensions {
|
||||
final value = capabilities['preserveNativeOutputExtensions'];
|
||||
if (value is! List) return const [];
|
||||
|
||||
final normalized = <String>[];
|
||||
for (final item in value) {
|
||||
if (item is! String) continue;
|
||||
final trimmed = item.trim().toLowerCase();
|
||||
if (trimmed.isEmpty) continue;
|
||||
normalized.add(trimmed.startsWith('.') ? trimmed : '.$trimmed');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
class SearchFilter {
|
||||
@@ -481,8 +496,10 @@ class ExtensionState {
|
||||
}
|
||||
|
||||
class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
static const _builtInMetadataProviders = ['qobuz', 'tidal'];
|
||||
AppLifecycleListener? _appLifecycleListener;
|
||||
bool _cleanupInFlight = false;
|
||||
Completer<void>? _initializationCompleter;
|
||||
|
||||
@override
|
||||
ExtensionState build() {
|
||||
@@ -520,6 +537,13 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
Future<void> initialize(String extensionsDir, String dataDir) async {
|
||||
if (state.isInitialized) return;
|
||||
if (_initializationCompleter != null) {
|
||||
await _initializationCompleter!.future;
|
||||
return;
|
||||
}
|
||||
|
||||
final completer = Completer<void>();
|
||||
_initializationCompleter = completer;
|
||||
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
@@ -531,6 +555,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
error: null,
|
||||
);
|
||||
_log.i('Extension system disabled on this platform');
|
||||
completer.complete();
|
||||
_initializationCompleter = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -544,6 +570,32 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
} catch (e) {
|
||||
_log.e('Failed to initialize extension system: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
} finally {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
if (identical(_initializationCompleter, completer)) {
|
||||
_initializationCompleter = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> waitForInitialization({
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
}) async {
|
||||
if (state.isInitialized || !PlatformBridge.supportsExtensionSystem) {
|
||||
return;
|
||||
}
|
||||
|
||||
final future = _initializationCompleter?.future;
|
||||
if (future == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await future.timeout(timeout);
|
||||
} on TimeoutException {
|
||||
_log.w('Timed out waiting for extension initialization after $timeout');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,6 +618,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
final list = await PlatformBridge.getInstalledExtensions();
|
||||
final extensions = list.map((e) => Extension.fromJson(e)).toList();
|
||||
state = state.copyWith(extensions: extensions);
|
||||
await _reconcileDownloadProviderPriority();
|
||||
_log.d('Loaded ${extensions.length} extensions');
|
||||
|
||||
for (final ext in extensions) {
|
||||
@@ -661,6 +714,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}).toList();
|
||||
|
||||
state = state.copyWith(extensions: extensions);
|
||||
await _reconcileDownloadProviderPriority();
|
||||
|
||||
if (!enabled && ext != null) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
@@ -685,6 +739,23 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reconcileDownloadProviderPriority() async {
|
||||
if (state.providerPriority.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final sanitized = _sanitizeDownloadProviderPriority(state.providerPriority);
|
||||
if (jsonEncode(sanitized) == jsonEncode(state.providerPriority)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_providerPriorityKey, jsonEncode(sanitized));
|
||||
await PlatformBridge.setProviderPriority(sanitized);
|
||||
state = state.copyWith(providerPriority: sanitized);
|
||||
_log.d('Reconciled provider priority after extension update: $sanitized');
|
||||
}
|
||||
|
||||
Future<bool> ensureSpotifyWebExtensionReady({
|
||||
bool setAsSearchProvider = true,
|
||||
}) async {
|
||||
@@ -812,6 +883,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
|
||||
List<String> _sanitizeDownloadProviderPriority(List<String> input) {
|
||||
final allowed = getAllDownloadProviders().toSet();
|
||||
final preferredOrder = getAllDownloadProviders();
|
||||
final result = <String>[];
|
||||
|
||||
for (final provider in input) {
|
||||
@@ -820,7 +892,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
for (final provider in const ['tidal', 'qobuz', 'deezer']) {
|
||||
for (final provider in preferredOrder) {
|
||||
if (!result.contains(provider)) {
|
||||
result.add(provider);
|
||||
}
|
||||
@@ -847,10 +919,15 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
);
|
||||
await PlatformBridge.setMetadataProviderPriority(priority);
|
||||
} else {
|
||||
priority = _sanitizeMetadataProviderPriority(
|
||||
await PlatformBridge.getMetadataProviderPriority(),
|
||||
);
|
||||
final backendPriority =
|
||||
await PlatformBridge.getMetadataProviderPriority();
|
||||
priority = _sanitizeMetadataProviderPriority(backendPriority);
|
||||
_log.d('Using default metadata provider priority: $priority');
|
||||
await prefs.setString(
|
||||
_metadataProviderPriorityKey,
|
||||
jsonEncode(priority),
|
||||
);
|
||||
await PlatformBridge.setMetadataProviderPriority(priority);
|
||||
}
|
||||
|
||||
state = state.copyWith(metadataProviderPriority: priority);
|
||||
@@ -896,7 +973,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
List<String> getAllDownloadProviders() {
|
||||
final providers = ['tidal', 'qobuz', 'deezer'];
|
||||
final providers = ['tidal', 'qobuz'];
|
||||
for (final ext in state.extensions) {
|
||||
if (ext.enabled && ext.hasDownloadProvider) {
|
||||
providers.add(ext.id);
|
||||
@@ -906,17 +983,26 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
|
||||
List<String> getAllMetadataProviders() {
|
||||
final providers = ['deezer', 'qobuz', 'tidal'];
|
||||
for (final ext in state.extensions) {
|
||||
if (ext.enabled && ext.hasMetadataProvider) {
|
||||
providers.add(ext.id);
|
||||
}
|
||||
}
|
||||
return providers;
|
||||
final metadataExtensions = state.extensions
|
||||
.where((ext) => ext.enabled && ext.hasMetadataProvider)
|
||||
.toList();
|
||||
final primarySearchMetadataExtensions = metadataExtensions
|
||||
.where((ext) => ext.searchBehavior?.primary == true)
|
||||
.map((ext) => ext.id);
|
||||
final otherMetadataExtensions = metadataExtensions
|
||||
.where((ext) => ext.searchBehavior?.primary != true)
|
||||
.map((ext) => ext.id);
|
||||
|
||||
return [
|
||||
...primarySearchMetadataExtensions,
|
||||
..._builtInMetadataProviders,
|
||||
...otherMetadataExtensions,
|
||||
];
|
||||
}
|
||||
|
||||
List<String> _sanitizeMetadataProviderPriority(List<String> input) {
|
||||
final allowed = getAllMetadataProviders().toSet();
|
||||
final preferredOrder = getAllMetadataProviders();
|
||||
final result = <String>[];
|
||||
|
||||
for (final provider in input) {
|
||||
@@ -925,7 +1011,18 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
||||
}
|
||||
}
|
||||
|
||||
for (final provider in const ['deezer', 'qobuz', 'tidal']) {
|
||||
final hasPreferredExtension = preferredOrder.any(
|
||||
(provider) => !_builtInMetadataProviders.contains(provider),
|
||||
);
|
||||
final hasSavedExtension = result.any(
|
||||
(provider) => !_builtInMetadataProviders.contains(provider),
|
||||
);
|
||||
|
||||
if (!hasSavedExtension && hasPreferredExtension) {
|
||||
return List<String>.from(preferredOrder);
|
||||
}
|
||||
|
||||
for (final provider in preferredOrder) {
|
||||
if (!result.contains(provider)) {
|
||||
result.add(provider);
|
||||
}
|
||||
|
||||
@@ -12,12 +12,13 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
const _settingsKey = 'app_settings';
|
||||
const _migrationVersionKey = 'settings_migration_version';
|
||||
const _currentMigrationVersion = 9;
|
||||
const _currentMigrationVersion = 10;
|
||||
const _spotifyClientSecretKey = 'spotify_client_secret';
|
||||
final _log = AppLogger('SettingsProvider');
|
||||
|
||||
class SettingsNotifier extends Notifier<AppSettings> {
|
||||
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
|
||||
static const Set<String> _searchTabValues = {'all', 'track', 'artist', 'album'};
|
||||
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||
@@ -35,9 +36,23 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
final prefs = await _prefs;
|
||||
final json = prefs.getString(_settingsKey);
|
||||
if (json != null) {
|
||||
state = AppSettings.fromJson(
|
||||
final loaded = AppSettings.fromJson(
|
||||
Map<String, dynamic>.from(jsonDecode(json) as Map),
|
||||
);
|
||||
final sanitizedDownloadFallbackExtensionIds =
|
||||
_sanitizeDownloadFallbackExtensionIds(
|
||||
loaded.downloadFallbackExtensionIds,
|
||||
);
|
||||
final sanitizedDefaultSearchTab = _normalizeDefaultSearchTab(
|
||||
loaded.defaultSearchTab,
|
||||
);
|
||||
state = loaded.copyWith(
|
||||
downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds,
|
||||
clearDownloadFallbackExtensionIds:
|
||||
loaded.downloadFallbackExtensionIds != null &&
|
||||
sanitizedDownloadFallbackExtensionIds == null,
|
||||
defaultSearchTab: sanitizedDefaultSearchTab,
|
||||
);
|
||||
|
||||
await _runMigrations(prefs);
|
||||
await _normalizeIosDownloadDirectoryIfNeeded();
|
||||
@@ -50,6 +65,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
|
||||
_syncLyricsSettingsToBackend();
|
||||
_syncNetworkCompatibilitySettingsToBackend();
|
||||
_syncExtensionFallbackSettingsToBackend();
|
||||
}
|
||||
|
||||
void _syncLyricsSettingsToBackend() {
|
||||
@@ -83,6 +99,16 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
});
|
||||
}
|
||||
|
||||
void _syncExtensionFallbackSettingsToBackend() {
|
||||
if (!PlatformBridge.supportsCoreBackend) return;
|
||||
|
||||
PlatformBridge.setDownloadFallbackExtensionIds(
|
||||
state.downloadFallbackExtensionIds,
|
||||
).catchError((Object e) {
|
||||
_log.w('Failed to sync extension fallback settings to backend: $e');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _runMigrations(SharedPreferences prefs) async {
|
||||
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
||||
|
||||
@@ -111,8 +137,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
);
|
||||
}
|
||||
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
||||
// Migration 7: YouTube is no longer a built-in service — reset to Tidal
|
||||
if (state.defaultService == 'youtube') {
|
||||
// Migration 7/10: retired built-in services reset back to Tidal
|
||||
if (state.defaultService == 'youtube' ||
|
||||
state.defaultService == 'deezer') {
|
||||
state = state.copyWith(defaultService: 'tidal');
|
||||
}
|
||||
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||
@@ -165,6 +192,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
return 'US';
|
||||
}
|
||||
|
||||
String _normalizeDefaultSearchTab(String value) {
|
||||
final normalized = value.trim().toLowerCase();
|
||||
if (_searchTabValues.contains(normalized)) return normalized;
|
||||
return 'all';
|
||||
}
|
||||
|
||||
Future<void> _normalizeSongLinkRegionIfNeeded() async {
|
||||
final normalized = _normalizeSongLinkRegion(state.songLinkRegion);
|
||||
if (normalized == state.songLinkRegion) return;
|
||||
@@ -172,6 +205,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
await _saveSettings();
|
||||
}
|
||||
|
||||
List<String>? _sanitizeDownloadFallbackExtensionIds(List<String>? ids) {
|
||||
if (ids == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final result = <String>[];
|
||||
for (final id in ids) {
|
||||
final normalized = id.trim();
|
||||
if (normalized.isEmpty || result.contains(normalized)) {
|
||||
continue;
|
||||
}
|
||||
result.add(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _cleanupRetiredSpotifySettings() async {
|
||||
final storedSecret = await _secureStorage.read(
|
||||
key: _spotifyClientSecretKey,
|
||||
@@ -370,6 +419,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setDefaultSearchTab(String tab) {
|
||||
state = state.copyWith(defaultSearchTab: _normalizeDefaultSearchTab(tab));
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHomeFeedProvider(String? provider) {
|
||||
if (provider == null || provider.isEmpty) {
|
||||
state = state.copyWith(clearHomeFeedProvider: true);
|
||||
@@ -390,6 +444,17 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setDownloadFallbackExtensionIds(List<String>? extensionIds) {
|
||||
final sanitized = _sanitizeDownloadFallbackExtensionIds(extensionIds);
|
||||
state = state.copyWith(
|
||||
downloadFallbackExtensionIds: sanitized,
|
||||
clearDownloadFallbackExtensionIds:
|
||||
extensionIds == null && state.downloadFallbackExtensionIds != null,
|
||||
);
|
||||
_saveSettings();
|
||||
_syncExtensionFallbackSettingsToBackend();
|
||||
}
|
||||
|
||||
void setSeparateSingles(bool enabled) {
|
||||
state = state.copyWith(separateSingles: enabled);
|
||||
_saveSettings();
|
||||
|
||||